"""Validated environment configuration for Raspberry Pi integrations."""

from __future__ import annotations

import base64
import binascii
import os
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Callable, Mapping, Optional, Tuple
from urllib.parse import urlsplit

from .datahub import DataHubPublisher
from .offload import ShortcutJobClient
from .ota import PrivilegedOTAClient, SecureOTAUpdater
from .outbox import DurableOutbox
from .readiness import DeviceReadinessClient


def _text(environment: Mapping[str, str], name: str) -> str:
    return str(environment.get(name, "") or "").strip()


def _boolean(
    environment: Mapping[str, str],
    name: str,
    default: bool = False,
) -> bool:
    raw = _text(environment, name)
    if not raw:
        return default
    value = raw.lower()
    if value in {"1", "true", "yes", "on"}:
        return True
    if value in {"0", "false", "no", "off"}:
        return False
    raise ValueError("{} must be true or false".format(name))


def _float(
    environment: Mapping[str, str],
    name: str,
    default: float,
    *,
    minimum: float,
    maximum: float,
) -> float:
    raw = _text(environment, name)
    if not raw:
        return default
    try:
        value = float(raw)
    except ValueError as exc:
        raise ValueError("{} must be a number".format(name)) from exc
    if not minimum <= value <= maximum:
        raise ValueError("{} must be between {} and {}".format(name, minimum, maximum))
    return value


def _integer(
    environment: Mapping[str, str],
    name: str,
    default: int,
    *,
    minimum: int,
    maximum: int,
) -> int:
    raw = _text(environment, name)
    if not raw:
        return default
    try:
        value = int(raw)
    except ValueError as exc:
        raise ValueError("{} must be an integer".format(name)) from exc
    if not minimum <= value <= maximum:
        raise ValueError("{} must be between {} and {}".format(name, minimum, maximum))
    return value


def _csv(environment: Mapping[str, str], name: str) -> Tuple[str, ...]:
    return tuple(
        value.strip() for value in _text(environment, name).split(",") if value.strip()
    )


def _xyz(environment: Mapping[str, str], name: str) -> Tuple[float, ...]:
    raw_values = _csv(environment, name)
    if not raw_values:
        return ()
    if len(raw_values) != 3:
        raise ValueError("{} must contain exactly three numbers".format(name))
    try:
        values = tuple(float(value) for value in raw_values)
    except ValueError as exc:
        raise ValueError("{} must contain exactly three numbers".format(name)) from exc
    if not all(float("-inf") < value < float("inf") for value in values):
        raise ValueError("{} values must be finite".format(name))
    return values


def _require_https_url(name: str, value: str) -> None:
    if not value:
        return
    parsed = urlsplit(value)
    if (
        parsed.scheme.lower() != "https"
        or not parsed.hostname
        or parsed.username is not None
        or parsed.password is not None
    ):
        raise ValueError("{} must be an HTTPS URL without credentials".format(name))


def _effective_port(parsed) -> int:
    try:
        explicit_port = parsed.port
    except ValueError as exc:
        raise ValueError("OTA endpoint contains an invalid port") from exc
    if explicit_port is not None:
        return explicit_port
    return 443 if parsed.scheme.lower() == "https" else 80


def _validate_ota_endpoint_contract(
    *,
    device_uid: str,
    manifest_url: str,
    report_url: str,
) -> None:
    manifest = urlsplit(manifest_url)
    report = urlsplit(report_url)
    manifest_origin = (
        manifest.scheme.lower(),
        (manifest.hostname or "").lower(),
        _effective_port(manifest),
    )
    report_origin = (
        report.scheme.lower(),
        (report.hostname or "").lower(),
        _effective_port(report),
    )
    if manifest_origin != report_origin:
        raise ValueError("LABCOBOT_OTA_REPORT_URL must share the manifest URL origin")

    manifest_path = "/device-objects-manager/api/devices/{}/ota/".format(device_uid)
    report_path = manifest_path + "report/"
    for name, parsed, expected_path in (
        ("LABCOBOT_OTA_MANIFEST_URL", manifest, manifest_path),
        ("LABCOBOT_OTA_REPORT_URL", report, report_path),
    ):
        if parsed.path != expected_path or parsed.query or parsed.fragment:
            raise ValueError(
                "{} must use the registered device OTA path {} without "
                "query or fragment".format(name, expected_path)
            )


@dataclass(frozen=True)
class IntegrationSettings:
    """One immutable, secret-preserving integration configuration."""

    state_dir: Path
    install_root: Path
    current_version: str
    deployment_mode: str = "development"
    immutable_release: bool = False
    device_uid: str = ""
    device_secret: str = ""
    manufacturer_id: str = ""
    device_readiness_url: str = ""
    registry_check_seconds: float = 300.0
    datahub_ingest_url: str = ""
    datahub_dataset_asset_id: int = 0
    datahub_flush_seconds: float = 5.0
    telemetry_sample_seconds: float = 1.0
    image_sample_seconds: float = 5.0
    ota_manifest_url: str = ""
    ota_report_url: str = ""
    ota_signing_key: bytes = b""
    ota_product_id: int = 0
    ota_privileged_request_dir: str = "/var/lib/labcobot/ota-requests"
    ota_privileged_status_path: str = "/var/lib/labcobot/privileged-ota/status.json"
    ota_allowed_download_hosts: Tuple[str, ...] = ()
    ota_poll_seconds: float = 0.0
    ota_auto_apply: bool = False
    ota_auto_apply_rollback: bool = False
    ota_commissioning_artifact_path: str = ""
    ota_commissioning_artifact_sha256: str = ""
    ota_controller_stopped_procedure_sha256: str = ""
    ota_filesystem_mount_profile_sha256: str = ""
    ota_boot_supervisor_path: str = "/usr/local/lib/labcobot/boot_supervisor.py"
    ota_lvgl_build_info_path: str = "/usr/local/lib/labcobot-lvgl/BUILD_INFO"
    ota_boot_health_seconds: float = 30.0
    ota_admission_lease_seconds: float = 120.0
    offload_submit_url: str = ""
    offload_status_url: str = ""
    offload_shortcut_id: str = ""
    offload_trajectory_shortcut_id: str = ""
    offload_valve_shortcut_id: str = ""
    offload_api_key: str = ""
    offload_bearer_token: str = ""
    offload_submit_mode: str = "json"
    offload_deadline_seconds: float = 300.0
    cameras_enabled: bool = False
    tool_camera_backend: str = "depthai"
    tool_camera_source: str = ""
    tool_camera_device_id: str = ""
    tool_camera_source_id: str = "tool"
    workspace_camera_backend: str = "opencv"
    workspace_camera_source: str = ""
    workspace_camera_device_id: str = ""
    workspace_camera_source_id: str = "workspace"
    camera_width: int = 1280
    camera_height: int = 720
    camera_fps: float = 15.0
    mjpeg_enabled: bool = False
    mjpeg_host: str = "127.0.0.1"
    mjpeg_port: int = 8765
    mjpeg_token: str = ""
    mjpeg_max_clients: int = 4
    xarm_metrics_enabled: bool = False
    xarm_ip: str = ""
    xarm_motion_enabled: bool = False
    xarm_model: str = ""
    motion_authorization_seconds: float = 30.0
    safety_profile_path: str = ""
    safety_profile_sha256: str = ""
    site_safety_profile_path: str = ""
    site_safety_profile_sha256: str = ""
    tray_fixture_profile_sha256: str = ""
    petri_process_profile_sha256: str = ""
    home_trajectory_sha256: str = ""
    recovery_trajectory_sha256: str = ""
    sampling_tool_enabled: bool = False
    sampling_tool_profile_path: str = ""
    sampling_tool_profile_sha256: str = ""
    capture_run_enabled: bool = False
    capture_dataset_root: str = ""
    hand_training_enabled: bool = False
    hand_training_upload_url: str = ""
    hand_training_sample_rate_hz: float = 5.0
    hand_training_max_duration_seconds: float = 600.0
    hand_training_max_video_bytes: int = 268_435_456
    capture_motion_profile_path: str = ""
    capture_motion_profile_sha256: str = ""
    capture_safety_evidence_path: str = ""
    capture_safety_evidence_sha256: str = ""
    capture_workspace_lower_xyz_mm: Tuple[float, ...] = ()
    capture_workspace_upper_xyz_mm: Tuple[float, ...] = ()
    capture_joint_limits_policy: str = ""
    eye_in_hand_enabled: bool = False
    eye_in_hand_calibration_path: str = ""
    eye_in_hand_calibration_sha256: str = ""
    eye_in_hand_workspace_lower_xyz_mm: Tuple[float, ...] = ()
    eye_in_hand_workspace_upper_xyz_mm: Tuple[float, ...] = ()
    eye_in_hand_pose_sample_seconds: float = 0.02
    dual_depth_preflight_enabled: bool = False
    workspace_depth_calibration_path: str = ""
    workspace_depth_calibration_sha256: str = ""
    dual_depth_geometry_profile_path: str = ""
    dual_depth_geometry_profile_sha256: str = ""
    dual_depth_required_clearance_mm: float = 75.0
    workspace_teach_enabled: bool = False
    workspace_teach_hard_lower_xyz_mm: Tuple[float, ...] = ()
    workspace_teach_hard_upper_xyz_mm: Tuple[float, ...] = ()
    workspace_teach_inset_xyz_mm: Tuple[float, ...] = ()
    workspace_teach_minimum_span_xyz_mm: Tuple[float, ...] = ()
    workspace_teach_robot_binding_sha256: str = ""
    workspace_teach_commissioning_permit_sha256: str = ""
    workspace_teach_proposal_root: str = ""
    workspace_boundary_validation_enabled: bool = False
    newton_workspace_urdf_path: str = ""
    newton_workspace_urdf_sha256: str = ""
    newton_workspace_version: str = "1.4.0"
    newton_workspace_device: str = "cpu"
    dynamic_valve_enabled: bool = False
    dynamic_valve_interval_seconds: float = 0.10
    dynamic_valve_min_confidence: float = 0.70
    dynamic_valve_default_normal_xyz: Tuple[float, ...] = ()
    dynamic_valve_remote_min_interval_seconds: float = 2.0
    dynamic_valve_primitive_path: str = ""
    dynamic_valve_primitive_sha256: str = ""
    dynamic_valve_servo_hz: float = 100.0
    dynamic_valve_local_model_enabled: bool = False
    dynamic_valve_local_model_path: str = ""
    dynamic_valve_local_model_sha256: str = ""
    dynamic_valve_local_model_manifest_path: str = ""
    dynamic_valve_local_model_manifest_sha256: str = ""
    petri_registry_path: str = ""
    petri_artifact_root: str = ""

    @classmethod
    def from_environment(
        cls,
        environment: Optional[Mapping[str, str]] = None,
        *,
        install_root: Optional[Path] = None,
        current_version: str = "",
        deployment_mode: str = "",
    ) -> "IntegrationSettings":
        env = os.environ if environment is None else environment
        root = (
            Path(install_root)
            if install_root is not None
            else Path(__file__).resolve().parents[2]
        )
        explicit_mode = str(deployment_mode).strip().lower()
        environment_mode = _text(env, "LABCOBOT_DEPLOYMENT_MODE").lower()
        if explicit_mode and environment_mode and explicit_mode != environment_mode:
            raise ValueError("runtime mode does not match LABCOBOT_DEPLOYMENT_MODE")
        version = current_version or _text(env, "LABCOBOT_VERSION") or "0.0.0"
        signing_key = _decode_signing_key(_text(env, "LABCOBOT_OTA_SIGNING_KEY_B64"))
        settings = cls(
            state_dir=Path(_text(env, "LABCOBOT_STATE_DIR") or "/var/lib/labcobot"),
            install_root=root,
            current_version=version,
            petri_registry_path=_text(env, "LABCOBOT_PETRI_REGISTRY_PATH"),
            petri_artifact_root=_text(env, "LABCOBOT_PETRI_ARTIFACT_ROOT"),
            deployment_mode=(explicit_mode or environment_mode or "development"),
            immutable_release=_boolean(env, "LABCOBOT_IMMUTABLE_RELEASE"),
            device_uid=_text(env, "LABCOBOT_DEVICE_UID"),
            device_secret=_text(env, "LABCOBOT_DEVICE_SECRET"),
            manufacturer_id=_text(env, "LABCOBOT_MANUFACTURER_ID"),
            device_readiness_url=_text(env, "LABCOBOT_DEVICE_READINESS_URL"),
            registry_check_seconds=_float(
                env,
                "LABCOBOT_REGISTRY_CHECK_SECONDS",
                300.0,
                minimum=5.0,
                maximum=86400.0,
            ),
            datahub_ingest_url=_text(env, "LABCOBOT_DATAHUB_INGEST_URL"),
            datahub_dataset_asset_id=_integer(
                env,
                "LABCOBOT_DATAHUB_DATASET_ASSET_ID",
                0,
                minimum=0,
                maximum=2_147_483_647,
            ),
            datahub_flush_seconds=_float(
                env,
                "LABCOBOT_DATAHUB_FLUSH_SECONDS",
                5.0,
                minimum=0.1,
                maximum=3600.0,
            ),
            telemetry_sample_seconds=_float(
                env,
                "LABCOBOT_TELEMETRY_SAMPLE_SECONDS",
                1.0,
                minimum=0.05,
                maximum=3600.0,
            ),
            image_sample_seconds=_float(
                env,
                "LABCOBOT_IMAGE_SAMPLE_SECONDS",
                5.0,
                minimum=0.1,
                maximum=3600.0,
            ),
            ota_manifest_url=_text(env, "LABCOBOT_OTA_MANIFEST_URL"),
            ota_report_url=_text(env, "LABCOBOT_OTA_REPORT_URL"),
            ota_signing_key=signing_key,
            ota_product_id=_integer(
                env,
                "LABCOBOT_OTA_PRODUCT_ID",
                0,
                minimum=0,
                maximum=2_147_483_647,
            ),
            ota_privileged_request_dir=(
                _text(env, "LABCOBOT_OTA_PRIVILEGED_REQUEST_DIR")
                or "/var/lib/labcobot/ota-requests"
            ),
            ota_privileged_status_path=(
                _text(env, "LABCOBOT_OTA_PRIVILEGED_STATUS_PATH")
                or "/var/lib/labcobot/privileged-ota/status.json"
            ),
            ota_allowed_download_hosts=_csv(env, "LABCOBOT_OTA_ALLOWED_DOWNLOAD_HOSTS"),
            ota_poll_seconds=_float(
                env,
                "LABCOBOT_OTA_POLL_SECONDS",
                0.0,
                minimum=0.0,
                maximum=604800.0,
            ),
            ota_auto_apply=_boolean(env, "LABCOBOT_OTA_AUTO_APPLY"),
            ota_auto_apply_rollback=_boolean(env, "LABCOBOT_OTA_AUTO_APPLY_ROLLBACK"),
            ota_commissioning_artifact_path=_text(
                env, "LABCOBOT_OTA_COMMISSIONING_ARTIFACT_PATH"
            ),
            ota_commissioning_artifact_sha256=_text(
                env, "LABCOBOT_OTA_COMMISSIONING_ARTIFACT_SHA256"
            ).lower(),
            ota_controller_stopped_procedure_sha256=_text(
                env, "LABCOBOT_OTA_CONTROLLER_STOPPED_PROCEDURE_SHA256"
            ).lower(),
            ota_filesystem_mount_profile_sha256=_text(
                env, "LABCOBOT_OTA_FILESYSTEM_MOUNT_PROFILE_SHA256"
            ).lower(),
            ota_boot_supervisor_path=(
                _text(env, "LABCOBOT_OTA_BOOT_SUPERVISOR_PATH")
                or "/usr/local/lib/labcobot/boot_supervisor.py"
            ),
            ota_lvgl_build_info_path=(
                _text(env, "LABCOBOT_OTA_LVGL_BUILD_INFO_PATH")
                or "/usr/local/lib/labcobot-lvgl/BUILD_INFO"
            ),
            ota_boot_health_seconds=_float(
                env,
                "LABCOBOT_OTA_BOOT_HEALTH_SECONDS",
                30.0,
                minimum=1.0,
                maximum=3600.0,
            ),
            ota_admission_lease_seconds=_float(
                env,
                "LABCOBOT_OTA_ADMISSION_LEASE_SECONDS",
                120.0,
                minimum=1.0,
                maximum=300.0,
            ),
            offload_submit_url=_text(env, "LABCOBOT_OFFLOAD_SUBMIT_URL"),
            offload_status_url=_text(env, "LABCOBOT_OFFLOAD_STATUS_URL"),
            offload_shortcut_id=_text(env, "LABCOBOT_OFFLOAD_SHORTCUT_ID"),
            offload_trajectory_shortcut_id=(
                _text(env, "LABCOBOT_OFFLOAD_TRAJECTORY_SHORTCUT_ID")
                or _text(env, "LABCOBOT_OFFLOAD_SHORTCUT_ID")
            ),
            offload_valve_shortcut_id=(
                _text(env, "LABCOBOT_OFFLOAD_VALVE_SHORTCUT_ID")
                or _text(env, "LABCOBOT_OFFLOAD_SHORTCUT_ID")
            ),
            offload_api_key=_text(env, "LABCOBOT_OFFLOAD_API_KEY"),
            offload_bearer_token=_text(env, "LABCOBOT_OFFLOAD_BEARER_TOKEN"),
            offload_submit_mode=(
                _text(env, "LABCOBOT_OFFLOAD_SUBMIT_MODE") or "json"
            ).lower(),
            offload_deadline_seconds=_float(
                env,
                "LABCOBOT_OFFLOAD_DEADLINE_SECONDS",
                300.0,
                minimum=1.0,
                maximum=86400.0,
            ),
            cameras_enabled=_boolean(env, "LABCOBOT_CAMERAS_ENABLED"),
            tool_camera_backend=(
                _text(env, "LABCOBOT_TOOL_CAMERA_BACKEND") or "depthai"
            ).lower(),
            tool_camera_source=_text(env, "LABCOBOT_TOOL_CAMERA_SOURCE"),
            tool_camera_device_id=_text(env, "LABCOBOT_TOOL_CAMERA_DEVICE_ID"),
            tool_camera_source_id=(
                _text(env, "LABCOBOT_TOOL_CAMERA_SOURCE_ID")
                or _text(env, "LABCOBOT_TOOL_CAMERA_DEVICE_ID")
                or "tool"
            ),
            workspace_camera_backend=(
                _text(env, "LABCOBOT_WORKSPACE_CAMERA_BACKEND") or "opencv"
            ).lower(),
            workspace_camera_source=_text(env, "LABCOBOT_WORKSPACE_CAMERA_SOURCE"),
            workspace_camera_device_id=_text(
                env, "LABCOBOT_WORKSPACE_CAMERA_DEVICE_ID"
            ),
            workspace_camera_source_id=(
                _text(env, "LABCOBOT_WORKSPACE_CAMERA_SOURCE_ID")
                or _text(env, "LABCOBOT_WORKSPACE_CAMERA_DEVICE_ID")
                or "workspace"
            ),
            camera_width=_integer(
                env,
                "LABCOBOT_CAMERA_WIDTH",
                1280,
                minimum=160,
                maximum=7680,
            ),
            camera_height=_integer(
                env,
                "LABCOBOT_CAMERA_HEIGHT",
                720,
                minimum=120,
                maximum=4320,
            ),
            camera_fps=_float(
                env,
                "LABCOBOT_CAMERA_FPS",
                15.0,
                minimum=0.1,
                maximum=240.0,
            ),
            mjpeg_enabled=_boolean(env, "LABCOBOT_MJPEG_ENABLED"),
            mjpeg_host=(_text(env, "LABCOBOT_MJPEG_HOST") or "127.0.0.1"),
            mjpeg_port=_integer(
                env,
                "LABCOBOT_MJPEG_PORT",
                8765,
                minimum=1,
                maximum=65535,
            ),
            mjpeg_token=_text(env, "LABCOBOT_MJPEG_TOKEN"),
            mjpeg_max_clients=_integer(
                env,
                "LABCOBOT_MJPEG_MAX_CLIENTS",
                4,
                minimum=1,
                maximum=64,
            ),
            xarm_metrics_enabled=_boolean(env, "LABCOBOT_XARM_METRICS_ENABLED"),
            xarm_ip=_text(env, "LABCOBOT_XARM_IP"),
            xarm_motion_enabled=_boolean(env, "LABCOBOT_XARM_MOTION_ENABLED"),
            xarm_model=_text(env, "LABCOBOT_XARM_MODEL").lower(),
            motion_authorization_seconds=_float(
                env,
                "LABCOBOT_MOTION_AUTHORIZATION_SECONDS",
                30.0,
                minimum=1.0,
                maximum=300.0,
            ),
            safety_profile_path=_text(env, "LABCOBOT_SAFETY_PROFILE_PATH"),
            safety_profile_sha256=_text(env, "LABCOBOT_SAFETY_PROFILE_SHA256").lower(),
            site_safety_profile_path=_text(env, "LABCOBOT_SITE_SAFETY_PROFILE_PATH"),
            site_safety_profile_sha256=_text(
                env, "LABCOBOT_SITE_SAFETY_PROFILE_SHA256"
            ).lower(),
            tray_fixture_profile_sha256=_text(
                env, "LABCOBOT_TRAY_FIXTURE_PROFILE_SHA256"
            ).lower(),
            petri_process_profile_sha256=_text(
                env, "LABCOBOT_PETRI_PROCESS_PROFILE_SHA256"
            ).lower(),
            home_trajectory_sha256=_text(
                env, "LABCOBOT_HOME_TRAJECTORY_SHA256"
            ).lower(),
            recovery_trajectory_sha256=_text(
                env, "LABCOBOT_RECOVERY_TRAJECTORY_SHA256"
            ).lower(),
            sampling_tool_enabled=_boolean(env, "LABCOBOT_SAMPLING_TOOL_ENABLED"),
            sampling_tool_profile_path=_text(
                env, "LABCOBOT_SAMPLING_TOOL_PROFILE_PATH"
            ),
            sampling_tool_profile_sha256=_text(
                env, "LABCOBOT_SAMPLING_TOOL_PROFILE_SHA256"
            ).lower(),
            capture_run_enabled=_boolean(env, "LABCOBOT_CAPTURE_RUN_ENABLED"),
            capture_dataset_root=_text(env, "LABCOBOT_CAPTURE_DATASET_ROOT"),
            hand_training_enabled=_boolean(env, "LABCOBOT_HAND_TRAINING_ENABLED"),
            hand_training_upload_url=_text(env, "LABCOBOT_HAND_TRAINING_UPLOAD_URL"),
            hand_training_sample_rate_hz=_float(
                env,
                "LABCOBOT_HAND_TRAINING_SAMPLE_RATE_HZ",
                5.0,
                minimum=0.2,
                maximum=30.0,
            ),
            hand_training_max_duration_seconds=_float(
                env,
                "LABCOBOT_HAND_TRAINING_MAX_DURATION_SECONDS",
                600.0,
                minimum=1.0,
                maximum=3600.0,
            ),
            hand_training_max_video_bytes=_integer(
                env,
                "LABCOBOT_HAND_TRAINING_MAX_VIDEO_BYTES",
                268_435_456,
                minimum=1_048_576,
                maximum=2_147_483_648,
            ),
            capture_motion_profile_path=_text(
                env, "LABCOBOT_CAPTURE_MOTION_PROFILE_PATH"
            ),
            capture_motion_profile_sha256=_text(
                env, "LABCOBOT_CAPTURE_MOTION_PROFILE_SHA256"
            ),
            capture_safety_evidence_path=_text(
                env, "LABCOBOT_CAPTURE_SAFETY_EVIDENCE_PATH"
            ),
            capture_safety_evidence_sha256=_text(
                env, "LABCOBOT_CAPTURE_SAFETY_EVIDENCE_SHA256"
            ),
            capture_workspace_lower_xyz_mm=_xyz(
                env, "LABCOBOT_CAPTURE_WORKSPACE_LOWER_XYZ_MM"
            ),
            capture_workspace_upper_xyz_mm=_xyz(
                env, "LABCOBOT_CAPTURE_WORKSPACE_UPPER_XYZ_MM"
            ),
            capture_joint_limits_policy=_text(
                env, "LABCOBOT_CAPTURE_JOINT_LIMITS_POLICY"
            ).lower(),
            eye_in_hand_enabled=_boolean(env, "LABCOBOT_EYE_IN_HAND_ENABLED"),
            eye_in_hand_calibration_path=_text(
                env, "LABCOBOT_EYE_IN_HAND_CALIBRATION_PATH"
            ),
            eye_in_hand_calibration_sha256=_text(
                env, "LABCOBOT_EYE_IN_HAND_CALIBRATION_SHA256"
            ).lower(),
            eye_in_hand_workspace_lower_xyz_mm=_xyz(
                env, "LABCOBOT_EYE_IN_HAND_WORKSPACE_LOWER_XYZ_MM"
            ),
            eye_in_hand_workspace_upper_xyz_mm=_xyz(
                env, "LABCOBOT_EYE_IN_HAND_WORKSPACE_UPPER_XYZ_MM"
            ),
            eye_in_hand_pose_sample_seconds=_float(
                env,
                "LABCOBOT_EYE_IN_HAND_POSE_SAMPLE_SECONDS",
                0.02,
                minimum=0.005,
                maximum=1.0,
            ),
            dual_depth_preflight_enabled=_boolean(
                env,
                "LABCOBOT_DUAL_DEPTH_PREFLIGHT_ENABLED",
            ),
            workspace_depth_calibration_path=_text(
                env,
                "LABCOBOT_WORKSPACE_DEPTH_CALIBRATION_PATH",
            ),
            workspace_depth_calibration_sha256=_text(
                env,
                "LABCOBOT_WORKSPACE_DEPTH_CALIBRATION_SHA256",
            ).lower(),
            dual_depth_geometry_profile_path=_text(
                env,
                "LABCOBOT_DUAL_DEPTH_GEOMETRY_PROFILE_PATH",
            ),
            dual_depth_geometry_profile_sha256=_text(
                env,
                "LABCOBOT_DUAL_DEPTH_GEOMETRY_PROFILE_SHA256",
            ).lower(),
            dual_depth_required_clearance_mm=_float(
                env,
                "LABCOBOT_DUAL_DEPTH_REQUIRED_CLEARANCE_MM",
                75.0,
                minimum=1.0,
                maximum=1_000.0,
            ),
            workspace_teach_enabled=_boolean(
                env,
                "LABCOBOT_WORKSPACE_TEACH_ENABLED",
            ),
            workspace_teach_hard_lower_xyz_mm=_xyz(
                env,
                "LABCOBOT_WORKSPACE_TEACH_HARD_LOWER_XYZ_MM",
            ),
            workspace_teach_hard_upper_xyz_mm=_xyz(
                env,
                "LABCOBOT_WORKSPACE_TEACH_HARD_UPPER_XYZ_MM",
            ),
            workspace_teach_inset_xyz_mm=_xyz(
                env,
                "LABCOBOT_WORKSPACE_TEACH_INSET_XYZ_MM",
            ),
            workspace_teach_minimum_span_xyz_mm=_xyz(
                env,
                "LABCOBOT_WORKSPACE_TEACH_MINIMUM_SPAN_XYZ_MM",
            ),
            workspace_teach_robot_binding_sha256=_text(
                env,
                "LABCOBOT_WORKSPACE_TEACH_ROBOT_BINDING_SHA256",
            ).lower(),
            workspace_teach_commissioning_permit_sha256=_text(
                env,
                "LABCOBOT_WORKSPACE_TEACH_COMMISSIONING_PERMIT_SHA256",
            ).lower(),
            workspace_teach_proposal_root=_text(
                env,
                "LABCOBOT_WORKSPACE_TEACH_PROPOSAL_ROOT",
            ),
            workspace_boundary_validation_enabled=_boolean(
                env,
                "LABCOBOT_WORKSPACE_BOUNDARY_VALIDATION_ENABLED",
            ),
            newton_workspace_urdf_path=_text(
                env,
                "LABCOBOT_NEWTON_WORKSPACE_URDF_PATH",
            ),
            newton_workspace_urdf_sha256=_text(
                env,
                "LABCOBOT_NEWTON_WORKSPACE_URDF_SHA256",
            ).lower(),
            newton_workspace_version=(
                _text(env, "LABCOBOT_NEWTON_WORKSPACE_VERSION") or "1.4.0"
            ),
            newton_workspace_device=(
                _text(env, "LABCOBOT_NEWTON_WORKSPACE_DEVICE") or "cpu"
            ).lower(),
            dynamic_valve_enabled=_boolean(env, "LABCOBOT_DYNAMIC_VALVE_ENABLED"),
            dynamic_valve_interval_seconds=_float(
                env,
                "LABCOBOT_DYNAMIC_VALVE_INTERVAL_SECONDS",
                0.10,
                minimum=0.02,
                maximum=10.0,
            ),
            dynamic_valve_min_confidence=_float(
                env,
                "LABCOBOT_DYNAMIC_VALVE_MIN_CONFIDENCE",
                0.70,
                minimum=0.0,
                maximum=1.0,
            ),
            dynamic_valve_default_normal_xyz=_xyz(
                env, "LABCOBOT_DYNAMIC_VALVE_DEFAULT_NORMAL_XYZ"
            ),
            dynamic_valve_remote_min_interval_seconds=_float(
                env,
                "LABCOBOT_DYNAMIC_VALVE_REMOTE_MIN_INTERVAL_SECONDS",
                2.0,
                minimum=0.1,
                maximum=3600.0,
            ),
            dynamic_valve_primitive_path=_text(
                env, "LABCOBOT_DYNAMIC_VALVE_PRIMITIVE_PATH"
            ),
            dynamic_valve_primitive_sha256=_text(
                env, "LABCOBOT_DYNAMIC_VALVE_PRIMITIVE_SHA256"
            ).lower(),
            dynamic_valve_servo_hz=_float(
                env,
                "LABCOBOT_DYNAMIC_VALVE_SERVO_HZ",
                100.0,
                minimum=10.0,
                maximum=100.0,
            ),
            dynamic_valve_local_model_enabled=_boolean(
                env,
                "LABCOBOT_DYNAMIC_VALVE_LOCAL_MODEL_ENABLED",
            ),
            dynamic_valve_local_model_path=_text(
                env,
                "LABCOBOT_DYNAMIC_VALVE_LOCAL_MODEL_PATH",
            ),
            dynamic_valve_local_model_sha256=_text(
                env,
                "LABCOBOT_DYNAMIC_VALVE_LOCAL_MODEL_SHA256",
            ).lower(),
            dynamic_valve_local_model_manifest_path=_text(
                env,
                "LABCOBOT_DYNAMIC_VALVE_LOCAL_MODEL_MANIFEST_PATH",
            ),
            dynamic_valve_local_model_manifest_sha256=_text(
                env,
                "LABCOBOT_DYNAMIC_VALVE_LOCAL_MODEL_MANIFEST_SHA256",
            ).lower(),
        )
        settings.validate()
        return settings

    @property
    def datahub_configured(self) -> bool:
        return bool(self.datahub_ingest_url)

    @property
    def registry_configured(self) -> bool:
        return bool(self.device_readiness_url)

    @property
    def ota_configured(self) -> bool:
        return bool(self.ota_manifest_url or self.ota_report_url)

    @property
    def offload_configured(self) -> bool:
        return bool(self.offload_submit_url or self.offload_status_url)

    @property
    def hand_training_configured(self) -> bool:
        """Return whether the explicit recording workflow is enabled."""

        return bool(self.hand_training_enabled and self.hand_training_upload_url)

    @property
    def local_valve_model_configured(self) -> bool:
        return self.dynamic_valve_local_model_enabled

    def validate(self) -> None:
        if self.deployment_mode not in {
            "development",
            "production",
            "gateway",
            "operator",
            "workcell",
        }:
            raise ValueError(
                "LABCOBOT_DEPLOYMENT_MODE must be development, production, "
                "gateway, operator, or workcell"
            )
        state_root = Path(self.state_dir)
        if not state_root.is_absolute() or ".." in state_root.parts:
            raise ValueError("LABCOBOT_STATE_DIR must be an absolute safe path")
        for name, raw_path in (
            ("LABCOBOT_PETRI_REGISTRY_PATH", self.petri_registry_path),
            ("LABCOBOT_PETRI_ARTIFACT_ROOT", self.petri_artifact_root),
        ):
            if not raw_path:
                continue
            petri_path = Path(raw_path)
            if not petri_path.is_absolute() or ".." in petri_path.parts:
                raise ValueError("{} must be an absolute safe path".format(name))
            try:
                relative_petri = petri_path.resolve(strict=False).relative_to(
                    state_root.resolve(strict=False)
                )
            except ValueError as exc:
                raise ValueError(
                    "{} must be below LABCOBOT_STATE_DIR".format(name)
                ) from exc
            if not relative_petri.parts:
                raise ValueError("{} must be below LABCOBOT_STATE_DIR".format(name))
        for name, value in (
            ("LABCOBOT_DEVICE_READINESS_URL", self.device_readiness_url),
            ("LABCOBOT_DATAHUB_INGEST_URL", self.datahub_ingest_url),
            ("LABCOBOT_OTA_MANIFEST_URL", self.ota_manifest_url),
            ("LABCOBOT_OTA_REPORT_URL", self.ota_report_url),
            ("LABCOBOT_OFFLOAD_SUBMIT_URL", self.offload_submit_url),
            ("LABCOBOT_OFFLOAD_STATUS_URL", self.offload_status_url),
            ("LABCOBOT_HAND_TRAINING_UPLOAD_URL", self.hand_training_upload_url),
        ):
            _require_https_url(name, value)
        for feature, enabled, fields in (
            (
                "device readiness",
                self.registry_configured,
                {
                    "LABCOBOT_DEVICE_UID": self.device_uid,
                    "LABCOBOT_DEVICE_SECRET": self.device_secret,
                },
            ),
            (
                "DataHub",
                self.datahub_configured,
                {
                    "LABCOBOT_DEVICE_UID": self.device_uid,
                    "LABCOBOT_DEVICE_SECRET": self.device_secret,
                },
            ),
            (
                "OTA",
                self.ota_configured,
                {
                    "LABCOBOT_DEVICE_UID": self.device_uid,
                    "LABCOBOT_DEVICE_SECRET": self.device_secret,
                    "LABCOBOT_OTA_MANIFEST_URL": self.ota_manifest_url,
                    "LABCOBOT_OTA_REPORT_URL": self.ota_report_url,
                    **(
                        {"LABCOBOT_OTA_PRODUCT_ID": self.ota_product_id}
                        if self.immutable_release
                        else {"LABCOBOT_OTA_SIGNING_KEY_B64": self.ota_signing_key}
                    ),
                },
            ),
            (
                "compute offload",
                self.offload_configured,
                {
                    "LABCOBOT_OFFLOAD_SUBMIT_URL": self.offload_submit_url,
                    "LABCOBOT_OFFLOAD_STATUS_URL": self.offload_status_url,
                    "LABCOBOT_OFFLOAD_TRAJECTORY_SHORTCUT_ID": (
                        self.offload_trajectory_shortcut_id or self.offload_shortcut_id
                    ),
                    "LABCOBOT_OFFLOAD_VALVE_SHORTCUT_ID": (
                        self.offload_valve_shortcut_id or self.offload_shortcut_id
                    ),
                    "LABCOBOT_DEVICE_UID": self.device_uid,
                    "LABCOBOT_DEVICE_SECRET": self.device_secret,
                },
            ),
            (
                "hand training",
                self.hand_training_enabled,
                {
                    "LABCOBOT_HAND_TRAINING_UPLOAD_URL": (
                        self.hand_training_upload_url
                    ),
                    "LABCOBOT_DEVICE_UID": self.device_uid,
                    "LABCOBOT_DEVICE_SECRET": self.device_secret,
                },
            ),
        ):
            missing = [name for name, value in fields.items() if not value]
            if enabled and missing:
                raise ValueError(
                    "{} configuration is incomplete: missing {}".format(
                        feature, ", ".join(missing)
                    )
                )
        if self.hand_training_enabled:
            if self.deployment_mode != "workcell":
                raise ValueError(
                    "LABCOBOT_HAND_TRAINING_ENABLED=true requires workcell deployment"
                )
            if not self.cameras_enabled or not self.xarm_metrics_enabled:
                raise ValueError(
                    "hand training requires both cameras and xArm telemetry"
                )
            parsed_training_url = urlsplit(self.hand_training_upload_url)
            expected_training_path = (
                "/api-gateway/devices/{}/hand-training/v1/recordings/".format(
                    self.device_uid
                )
            )
            if (
                parsed_training_url.path != expected_training_path
                or parsed_training_url.query
                or parsed_training_url.fragment
            ):
                raise ValueError(
                    "LABCOBOT_HAND_TRAINING_UPLOAD_URL must use the registered "
                    "device hand-training API-gateway path"
                )
        if self.ota_configured:
            _validate_ota_endpoint_contract(
                device_uid=self.device_uid,
                manifest_url=self.ota_manifest_url,
                report_url=self.ota_report_url,
            )
            if self.immutable_release:
                if self.ota_signing_key:
                    raise ValueError(
                        "Immutable production OTA must not contain a publisher secret"
                    )
                for name, value in (
                    (
                        "LABCOBOT_OTA_PRIVILEGED_REQUEST_DIR",
                        self.ota_privileged_request_dir,
                    ),
                    (
                        "LABCOBOT_OTA_PRIVILEGED_STATUS_PATH",
                        self.ota_privileged_status_path,
                    ),
                ):
                    path = Path(value)
                    if not path.is_absolute() or ".." in path.parts:
                        raise ValueError(
                            "{} must be an absolute safe path".format(name)
                        )
        if self.ota_auto_apply:
            if not self.ota_configured:
                raise ValueError(
                    "LABCOBOT_OTA_AUTO_APPLY=true requires complete OTA "
                    "configuration"
                )
            if not 0.0 < self.ota_poll_seconds <= 300.0:
                raise ValueError(
                    "LABCOBOT_OTA_AUTO_APPLY=true requires "
                    "LABCOBOT_OTA_POLL_SECONDS greater than 0 and no greater "
                    "than 300"
                )
            if not 0.0 < self.ota_admission_lease_seconds <= 300.0:
                raise ValueError(
                    "LABCOBOT_OTA_AUTO_APPLY=true requires "
                    "LABCOBOT_OTA_ADMISSION_LEASE_SECONDS greater than 0 and "
                    "no greater than 300"
                )
            if not self.cameras_enabled:
                raise ValueError("LABCOBOT_OTA_AUTO_APPLY=true requires dual cameras")
            if not self.xarm_metrics_enabled:
                raise ValueError("LABCOBOT_OTA_AUTO_APPLY=true requires xArm telemetry")
            if self.xarm_model != "lite6":
                raise ValueError(
                    "LABCOBOT_OTA_AUTO_APPLY=true requires " "LABCOBOT_XARM_MODEL=lite6"
                )
        for name, value in (
            (
                "LABCOBOT_OTA_COMMISSIONING_ARTIFACT_PATH",
                self.ota_commissioning_artifact_path,
            ),
            ("LABCOBOT_OTA_LVGL_BUILD_INFO_PATH", self.ota_lvgl_build_info_path),
            (
                "LABCOBOT_OTA_BOOT_SUPERVISOR_PATH",
                self.ota_boot_supervisor_path,
            ),
        ):
            if value:
                path = Path(value)
                if not path.is_absolute() or ".." in path.parts:
                    raise ValueError("{} must be an absolute safe path".format(name))
        for name, value in (
            (
                "LABCOBOT_OTA_COMMISSIONING_ARTIFACT_SHA256",
                self.ota_commissioning_artifact_sha256,
            ),
            (
                "LABCOBOT_OTA_CONTROLLER_STOPPED_PROCEDURE_SHA256",
                self.ota_controller_stopped_procedure_sha256,
            ),
            (
                "LABCOBOT_OTA_FILESYSTEM_MOUNT_PROFILE_SHA256",
                self.ota_filesystem_mount_profile_sha256,
            ),
        ):
            if value and (
                len(value) != 64 or re.fullmatch(r"[0-9a-f]{64}", value) is None
            ):
                raise ValueError("{} must be a lowercase SHA-256".format(name))
        if self.offload_submit_mode not in {"json", "form"}:
            raise ValueError("LABCOBOT_OFFLOAD_SUBMIT_MODE must be json or form")
        if self.cameras_enabled:
            camera_roles = (
                (
                    "TOOL",
                    self.tool_camera_backend,
                    self.tool_camera_source,
                    self.tool_camera_source_id,
                ),
                (
                    "WORKSPACE",
                    self.workspace_camera_backend,
                    self.workspace_camera_source,
                    self.workspace_camera_source_id,
                ),
            )
            for role, backend, source, source_id in camera_roles:
                if backend not in {"depthai", "opencv"}:
                    raise ValueError(
                        "LABCOBOT_{}_CAMERA_BACKEND must be depthai or "
                        "opencv".format(role)
                    )
                if backend == "opencv" and not source:
                    raise ValueError(
                        "OpenCV {} camera requires "
                        "LABCOBOT_{}_CAMERA_SOURCE".format(
                            role.lower(),
                            role,
                        )
                    )
                if not re.fullmatch(
                    r"[A-Za-z0-9_.:-]{1,255}",
                    source_id,
                ):
                    raise ValueError(
                        "LABCOBOT_{}_CAMERA_SOURCE_ID contains characters "
                        "unsupported by DataHub".format(role)
                    )
            if self.tool_camera_source_id == self.workspace_camera_source_id:
                raise ValueError("Dual cameras require distinct stable source IDs")
            if (
                self.tool_camera_backend == "depthai"
                and self.workspace_camera_backend == "depthai"
            ):
                missing_device_ids = [
                    name
                    for name, value in (
                        (
                            "LABCOBOT_TOOL_CAMERA_DEVICE_ID",
                            self.tool_camera_device_id,
                        ),
                        (
                            "LABCOBOT_WORKSPACE_CAMERA_DEVICE_ID",
                            self.workspace_camera_device_id,
                        ),
                    )
                    if not value
                ]
                if missing_device_ids:
                    raise ValueError(
                        "Dual DepthAI cameras require {}".format(
                            ", ".join(missing_device_ids)
                        )
                    )
                if self.tool_camera_device_id == self.workspace_camera_device_id:
                    raise ValueError("Dual DepthAI cameras require distinct device IDs")
        if self.mjpeg_enabled and not self.cameras_enabled:
            raise ValueError("MJPEG streaming requires dual cameras")
        if self.mjpeg_enabled and self.mjpeg_host not in {
            "127.0.0.1",
            "::1",
            "localhost",
        }:
            raise ValueError(
                "loopback MJPEG is required; expose remote video only through "
                "an authenticated TLS proxy or SSH tunnel"
            )
        if self.xarm_metrics_enabled and not self.xarm_ip:
            raise ValueError("xArm metrics require LABCOBOT_XARM_IP")
        safety_profile_fields = (
            ("LABCOBOT_SAFETY_PROFILE_PATH", self.safety_profile_path),
            ("LABCOBOT_SAFETY_PROFILE_SHA256", self.safety_profile_sha256),
        )
        if any(value for _name, value in safety_profile_fields):
            missing = [name for name, value in safety_profile_fields if not value]
            if missing:
                raise ValueError(
                    "safety profile configuration is incomplete: missing {}".format(
                        ", ".join(missing)
                    )
                )
            if not re.fullmatch(r"[0-9a-f]{64}", self.safety_profile_sha256):
                raise ValueError(
                    "LABCOBOT_SAFETY_PROFILE_SHA256 must contain exactly 64 "
                    "lowercase hexadecimal characters"
                )
            profile = Path(self.safety_profile_path)
            try:
                relative_profile = profile.relative_to(Path("/etc/labcobot"))
            except ValueError as exc:
                raise ValueError(
                    "LABCOBOT_SAFETY_PROFILE_PATH must be below /etc/labcobot"
                ) from exc
            if (
                not profile.is_absolute()
                or ".." in profile.parts
                or not relative_profile.parts
            ):
                raise ValueError("LABCOBOT_SAFETY_PROFILE_PATH is unsafe")
            if not self.xarm_metrics_enabled or self.xarm_model != "lite6":
                raise ValueError(
                    "safety profile configuration requires Lite6 telemetry"
                )
        site_safety_fields = (
            (
                "LABCOBOT_SITE_SAFETY_PROFILE_PATH",
                self.site_safety_profile_path,
            ),
            (
                "LABCOBOT_SITE_SAFETY_PROFILE_SHA256",
                self.site_safety_profile_sha256,
            ),
        )
        if any(value for _name, value in site_safety_fields):
            missing = [name for name, value in site_safety_fields if not value]
            if missing:
                raise ValueError(
                    "site-safety profile configuration is incomplete: missing "
                    + ", ".join(missing)
                )
            current_bindings = (
                (
                    "LABCOBOT_TRAY_FIXTURE_PROFILE_SHA256",
                    self.tray_fixture_profile_sha256,
                ),
                (
                    "LABCOBOT_PETRI_PROCESS_PROFILE_SHA256",
                    self.petri_process_profile_sha256,
                ),
                (
                    "LABCOBOT_HOME_TRAJECTORY_SHA256",
                    self.home_trajectory_sha256,
                ),
                (
                    "LABCOBOT_RECOVERY_TRAJECTORY_SHA256",
                    self.recovery_trajectory_sha256,
                ),
            )
            missing = [name for name, value in current_bindings if not value]
            if missing:
                raise ValueError(
                    "site-safety runtime bindings are incomplete: missing "
                    + ", ".join(missing)
                )
            for name, digest in (
                (
                    "LABCOBOT_SITE_SAFETY_PROFILE_SHA256",
                    self.site_safety_profile_sha256,
                ),
            ) + current_bindings:
                if re.fullmatch(r"[0-9a-f]{64}", digest) is None:
                    raise ValueError(
                        "{} must contain exactly 64 lowercase hexadecimal "
                        "characters".format(name)
                    )
            profile = Path(self.site_safety_profile_path)
            try:
                relative_profile = profile.relative_to(Path("/etc/labcobot"))
            except ValueError as exc:
                raise ValueError(
                    "LABCOBOT_SITE_SAFETY_PROFILE_PATH must be below /etc/labcobot"
                ) from exc
            if (
                not profile.is_absolute()
                or ".." in profile.parts
                or not relative_profile.parts
            ):
                raise ValueError("LABCOBOT_SITE_SAFETY_PROFILE_PATH is unsafe")
            required_site_inputs = (
                self.device_uid,
                self.safety_profile_path,
                self.safety_profile_sha256,
                self.sampling_tool_enabled,
                self.sampling_tool_profile_sha256,
                self.dual_depth_preflight_enabled,
                self.dual_depth_geometry_profile_sha256,
                self.eye_in_hand_calibration_sha256,
                self.workspace_depth_calibration_sha256,
                self.tool_camera_device_id,
                self.workspace_camera_device_id,
            )
            if not all(required_site_inputs):
                raise ValueError(
                    "site-safety commissioning requires the bound device, "
                    "controller, sampling tool, dual-depth geometry, both "
                    "calibrations, and both camera identities"
                )
        sampling_tool_fields = (
            (
                "LABCOBOT_SAMPLING_TOOL_PROFILE_PATH",
                self.sampling_tool_profile_path,
            ),
            (
                "LABCOBOT_SAMPLING_TOOL_PROFILE_SHA256",
                self.sampling_tool_profile_sha256,
            ),
        )
        if self.sampling_tool_enabled or any(
            value for _name, value in sampling_tool_fields
        ):
            if not self.sampling_tool_enabled:
                raise ValueError(
                    "sampling-tool profile requires "
                    "LABCOBOT_SAMPLING_TOOL_ENABLED=true"
                )
            missing = [name for name, value in sampling_tool_fields if not value]
            if missing:
                raise ValueError(
                    "sampling-tool configuration is incomplete: missing {}".format(
                        ", ".join(missing)
                    )
                )
            if not re.fullmatch(r"[0-9a-f]{64}", self.sampling_tool_profile_sha256):
                raise ValueError(
                    "LABCOBOT_SAMPLING_TOOL_PROFILE_SHA256 must contain "
                    "exactly 64 lowercase hexadecimal characters"
                )
            profile = Path(self.sampling_tool_profile_path)
            try:
                relative_profile = profile.relative_to(Path("/etc/labcobot"))
            except ValueError as exc:
                raise ValueError(
                    "LABCOBOT_SAMPLING_TOOL_PROFILE_PATH must be below /etc/labcobot"
                ) from exc
            if (
                not profile.is_absolute()
                or ".." in profile.parts
                or not relative_profile.parts
            ):
                raise ValueError("LABCOBOT_SAMPLING_TOOL_PROFILE_PATH is unsafe")
            if not (
                self.xarm_motion_enabled
                and self.xarm_metrics_enabled
                and self.xarm_model == "lite6"
                and self.safety_profile_path
                and self.safety_profile_sha256
            ):
                raise ValueError(
                    "sampling tool requires reviewed Lite6 motion and its "
                    "pinned safety profile"
                )
        if self.eye_in_hand_enabled:
            missing = []
            for name, value in (
                (
                    "LABCOBOT_EYE_IN_HAND_CALIBRATION_PATH",
                    self.eye_in_hand_calibration_path,
                ),
                (
                    "LABCOBOT_EYE_IN_HAND_WORKSPACE_LOWER_XYZ_MM",
                    self.eye_in_hand_workspace_lower_xyz_mm,
                ),
                (
                    "LABCOBOT_EYE_IN_HAND_WORKSPACE_UPPER_XYZ_MM",
                    self.eye_in_hand_workspace_upper_xyz_mm,
                ),
                (
                    "LABCOBOT_TOOL_CAMERA_DEVICE_ID",
                    self.tool_camera_device_id,
                ),
                ("LABCOBOT_TOOL_CAMERA_SOURCE_ID", self.tool_camera_source_id),
            ):
                if not value:
                    missing.append(name)
            if missing:
                raise ValueError(
                    "eye-in-hand configuration is incomplete: missing {}".format(
                        ", ".join(missing)
                    )
                )
            if not self.cameras_enabled:
                raise ValueError("eye-in-hand mapping requires dual cameras")
            if self.tool_camera_backend != "depthai":
                raise ValueError("eye-in-hand mapping requires the DepthAI tool camera")
            if self.tool_camera_source_id != self.tool_camera_device_id:
                raise ValueError(
                    "eye-in-hand tool camera source ID must equal its OAK MXID"
                )
            if not self.xarm_metrics_enabled:
                raise ValueError("eye-in-hand mapping requires xArm pose sampling")
            if (
                len(self.eye_in_hand_workspace_lower_xyz_mm) != 3
                or len(self.eye_in_hand_workspace_upper_xyz_mm) != 3
            ):
                raise ValueError(
                    "eye-in-hand workspace bounds must each contain three values"
                )
            if any(
                low >= high
                for low, high in zip(
                    self.eye_in_hand_workspace_lower_xyz_mm,
                    self.eye_in_hand_workspace_upper_xyz_mm,
                )
            ):
                raise ValueError(
                    "eye-in-hand workspace lower bounds must be below upper bounds"
                )
        if self.dual_depth_preflight_enabled:
            required = (
                (
                    "LABCOBOT_EYE_IN_HAND_CALIBRATION_SHA256",
                    self.eye_in_hand_calibration_sha256,
                ),
                (
                    "LABCOBOT_WORKSPACE_DEPTH_CALIBRATION_PATH",
                    self.workspace_depth_calibration_path,
                ),
                (
                    "LABCOBOT_WORKSPACE_DEPTH_CALIBRATION_SHA256",
                    self.workspace_depth_calibration_sha256,
                ),
                (
                    "LABCOBOT_DUAL_DEPTH_GEOMETRY_PROFILE_PATH",
                    self.dual_depth_geometry_profile_path,
                ),
                (
                    "LABCOBOT_DUAL_DEPTH_GEOMETRY_PROFILE_SHA256",
                    self.dual_depth_geometry_profile_sha256,
                ),
            )
            missing = [name for name, value in required if not value]
            if missing:
                raise ValueError(
                    "dual-depth preflight configuration is incomplete: missing "
                    + ", ".join(missing)
                )
            if not self.eye_in_hand_enabled or not self.dynamic_valve_enabled:
                raise ValueError(
                    "dual-depth preflight requires eye-in-hand mapping and "
                    "dynamic valve planning"
                )
            if (
                not self.cameras_enabled
                or self.tool_camera_backend != "depthai"
                or self.workspace_camera_backend != "depthai"
            ):
                raise ValueError("dual-depth preflight requires two DepthAI cameras")
            if (
                self.tool_camera_source_id != self.tool_camera_device_id
                or self.workspace_camera_source_id != self.workspace_camera_device_id
            ):
                raise ValueError(
                    "dual-depth camera source IDs must equal their OAK MXIDs"
                )
            if not self.xarm_metrics_enabled or self.xarm_model != "lite6":
                raise ValueError("dual-depth preflight requires Lite6 telemetry")
            for name, digest in (
                (
                    "LABCOBOT_EYE_IN_HAND_CALIBRATION_SHA256",
                    self.eye_in_hand_calibration_sha256,
                ),
                (
                    "LABCOBOT_WORKSPACE_DEPTH_CALIBRATION_SHA256",
                    self.workspace_depth_calibration_sha256,
                ),
                (
                    "LABCOBOT_DUAL_DEPTH_GEOMETRY_PROFILE_SHA256",
                    self.dual_depth_geometry_profile_sha256,
                ),
            ):
                if not re.fullmatch(r"[0-9a-f]{64}", digest):
                    raise ValueError(
                        "{} must contain exactly 64 lowercase hexadecimal "
                        "characters".format(name)
                    )
            for name, raw_path in (
                (
                    "LABCOBOT_EYE_IN_HAND_CALIBRATION_PATH",
                    self.eye_in_hand_calibration_path,
                ),
                (
                    "LABCOBOT_WORKSPACE_DEPTH_CALIBRATION_PATH",
                    self.workspace_depth_calibration_path,
                ),
                (
                    "LABCOBOT_DUAL_DEPTH_GEOMETRY_PROFILE_PATH",
                    self.dual_depth_geometry_profile_path,
                ),
            ):
                artifact = Path(raw_path)
                try:
                    relative_artifact = artifact.relative_to(Path("/etc/labcobot"))
                except ValueError as exc:
                    raise ValueError(
                        "{} must be below /etc/labcobot".format(name)
                    ) from exc
                if (
                    not artifact.is_absolute()
                    or ".." in artifact.parts
                    or not relative_artifact.parts
                ):
                    raise ValueError("{} path is unsafe".format(name))
        if self.workspace_teach_enabled:
            required = (
                (
                    "LABCOBOT_WORKSPACE_TEACH_HARD_LOWER_XYZ_MM",
                    self.workspace_teach_hard_lower_xyz_mm,
                ),
                (
                    "LABCOBOT_WORKSPACE_TEACH_HARD_UPPER_XYZ_MM",
                    self.workspace_teach_hard_upper_xyz_mm,
                ),
                (
                    "LABCOBOT_WORKSPACE_TEACH_INSET_XYZ_MM",
                    self.workspace_teach_inset_xyz_mm,
                ),
                (
                    "LABCOBOT_WORKSPACE_TEACH_MINIMUM_SPAN_XYZ_MM",
                    self.workspace_teach_minimum_span_xyz_mm,
                ),
                (
                    "LABCOBOT_WORKSPACE_TEACH_ROBOT_BINDING_SHA256",
                    self.workspace_teach_robot_binding_sha256,
                ),
                (
                    "LABCOBOT_WORKSPACE_TEACH_COMMISSIONING_PERMIT_SHA256",
                    self.workspace_teach_commissioning_permit_sha256,
                ),
                (
                    "LABCOBOT_WORKSPACE_TEACH_PROPOSAL_ROOT",
                    self.workspace_teach_proposal_root,
                ),
            )
            missing = [name for name, value in required if not value]
            if missing:
                raise ValueError(
                    "workspace-teach configuration is incomplete: missing "
                    + ", ".join(missing)
                )
            if not self.xarm_metrics_enabled or self.xarm_model != "lite6":
                raise ValueError("workspace teach requires read-only Lite6 telemetry")
            vectors = (
                self.workspace_teach_hard_lower_xyz_mm,
                self.workspace_teach_hard_upper_xyz_mm,
                self.workspace_teach_inset_xyz_mm,
                self.workspace_teach_minimum_span_xyz_mm,
            )
            if any(len(value) != 3 for value in vectors):
                raise ValueError(
                    "workspace-teach bounds, inset, and span must each contain "
                    "three values"
                )
            if any(
                lower >= upper
                for lower, upper in zip(
                    self.workspace_teach_hard_lower_xyz_mm,
                    self.workspace_teach_hard_upper_xyz_mm,
                )
            ):
                raise ValueError("workspace-teach hard envelope is invalid")
            if any(
                value <= 0.0
                for value in self.workspace_teach_inset_xyz_mm
                + self.workspace_teach_minimum_span_xyz_mm
            ):
                raise ValueError("workspace-teach inset and span must be positive")
            if any(
                minimum + 2.0 * inset > upper - lower
                for minimum, inset, lower, upper in zip(
                    self.workspace_teach_minimum_span_xyz_mm,
                    self.workspace_teach_inset_xyz_mm,
                    self.workspace_teach_hard_lower_xyz_mm,
                    self.workspace_teach_hard_upper_xyz_mm,
                )
            ):
                raise ValueError(
                    "workspace-teach hard envelope cannot contain the inset span"
                )
            for name, digest in (
                (
                    "LABCOBOT_WORKSPACE_TEACH_ROBOT_BINDING_SHA256",
                    self.workspace_teach_robot_binding_sha256,
                ),
                (
                    "LABCOBOT_WORKSPACE_TEACH_COMMISSIONING_PERMIT_SHA256",
                    self.workspace_teach_commissioning_permit_sha256,
                ),
            ):
                if not re.fullmatch(r"[0-9a-f]{64}", digest):
                    raise ValueError(
                        "{} must contain exactly 64 lowercase hexadecimal "
                        "characters".format(name)
                    )
            proposal_root = Path(self.workspace_teach_proposal_root)
            try:
                relative_root = proposal_root.relative_to(Path("/var/lib/labcobot"))
            except ValueError as exc:
                raise ValueError(
                    "LABCOBOT_WORKSPACE_TEACH_PROPOSAL_ROOT must be below "
                    "/var/lib/labcobot"
                ) from exc
            if (
                not proposal_root.is_absolute()
                or ".." in proposal_root.parts
                or not relative_root.parts
            ):
                raise ValueError(
                    "LABCOBOT_WORKSPACE_TEACH_PROPOSAL_ROOT path is unsafe"
                )
        if self.workspace_boundary_validation_enabled:
            if not self.workspace_teach_enabled:
                raise ValueError(
                    "workspace boundary validation requires workspace teach"
                )
            if not self.dual_depth_preflight_enabled:
                raise ValueError(
                    "workspace boundary validation requires dual-depth preflight"
                )
            if not self.newton_workspace_urdf_path:
                raise ValueError("LABCOBOT_NEWTON_WORKSPACE_URDF_PATH is required")
            if not re.fullmatch(r"[0-9a-f]{64}", self.newton_workspace_urdf_sha256):
                raise ValueError(
                    "LABCOBOT_NEWTON_WORKSPACE_URDF_SHA256 must contain exactly "
                    "64 lowercase hexadecimal characters"
                )
            if not re.fullmatch(
                r"[0-9]+\.[0-9]+\.[0-9]+", self.newton_workspace_version
            ):
                raise ValueError("LABCOBOT_NEWTON_WORKSPACE_VERSION is invalid")
            if self.newton_workspace_device not in {"cpu", "cuda"}:
                raise ValueError("LABCOBOT_NEWTON_WORKSPACE_DEVICE must be cpu or cuda")
            model_path = Path(self.newton_workspace_urdf_path)
            app_root = Path(__file__).resolve().parents[2]
            try:
                relative_model = model_path.resolve().relative_to(app_root.resolve())
            except (OSError, ValueError) as exc:
                raise ValueError(
                    "LABCOBOT_NEWTON_WORKSPACE_URDF_PATH must be below the app root"
                ) from exc
            if (
                not model_path.is_absolute()
                or ".." in model_path.parts
                or not relative_model.parts
            ):
                raise ValueError("LABCOBOT_NEWTON_WORKSPACE_URDF_PATH path is unsafe")
        if self.dynamic_valve_enabled and not self.eye_in_hand_enabled:
            raise ValueError(
                "dynamic tool-camera valve targeting requires eye-in-hand mapping"
            )
        if self.dynamic_valve_enabled:
            normal = self.dynamic_valve_default_normal_xyz
            if len(normal) != 3 or sum(value * value for value in normal) <= 1e-12:
                raise ValueError(
                    "dynamic valve targeting requires a non-zero "
                    "LABCOBOT_DYNAMIC_VALVE_DEFAULT_NORMAL_XYZ"
                )
        local_model_fields = (
            (
                "LABCOBOT_DYNAMIC_VALVE_LOCAL_MODEL_PATH",
                self.dynamic_valve_local_model_path,
            ),
            (
                "LABCOBOT_DYNAMIC_VALVE_LOCAL_MODEL_SHA256",
                self.dynamic_valve_local_model_sha256,
            ),
            (
                "LABCOBOT_DYNAMIC_VALVE_LOCAL_MODEL_MANIFEST_PATH",
                self.dynamic_valve_local_model_manifest_path,
            ),
            (
                "LABCOBOT_DYNAMIC_VALVE_LOCAL_MODEL_MANIFEST_SHA256",
                self.dynamic_valve_local_model_manifest_sha256,
            ),
        )
        if any(value for _name, value in local_model_fields) and not (
            self.dynamic_valve_local_model_enabled
        ):
            raise ValueError(
                "reviewed local valve model fields require "
                "LABCOBOT_DYNAMIC_VALVE_LOCAL_MODEL_ENABLED=true"
            )
        if self.dynamic_valve_local_model_enabled:
            if self.deployment_mode != "operator":
                raise ValueError(
                    "reviewed local valve confirmation is restricted to "
                    "operator deployment"
                )
            missing_local_model = [
                name for name, value in local_model_fields if not value
            ]
            if missing_local_model:
                raise ValueError(
                    "reviewed local valve model configuration is incomplete: "
                    "missing {}".format(", ".join(missing_local_model))
                )
            for name, digest in (
                (
                    "LABCOBOT_DYNAMIC_VALVE_LOCAL_MODEL_SHA256",
                    self.dynamic_valve_local_model_sha256,
                ),
                (
                    "LABCOBOT_DYNAMIC_VALVE_LOCAL_MODEL_MANIFEST_SHA256",
                    self.dynamic_valve_local_model_manifest_sha256,
                ),
            ):
                if not re.fullmatch(r"[0-9a-f]{64}", digest):
                    raise ValueError(
                        "{} must contain exactly 64 lowercase hexadecimal "
                        "characters".format(name)
                    )
            artifact_root = Path("/etc/labcobot")
            local_artifacts = []
            for name, raw_path in (
                (
                    "LABCOBOT_DYNAMIC_VALVE_LOCAL_MODEL_PATH",
                    self.dynamic_valve_local_model_path,
                ),
                (
                    "LABCOBOT_DYNAMIC_VALVE_LOCAL_MODEL_MANIFEST_PATH",
                    self.dynamic_valve_local_model_manifest_path,
                ),
            ):
                artifact = Path(raw_path)
                try:
                    relative_artifact = artifact.relative_to(artifact_root)
                except ValueError as exc:
                    raise ValueError(
                        "{} must be below /etc/labcobot".format(name)
                    ) from exc
                if (
                    not artifact.is_absolute()
                    or ".." in artifact.parts
                    or not relative_artifact.parts
                ):
                    raise ValueError("{} path is unsafe".format(name))
                local_artifacts.append(artifact)
            if local_artifacts[0] == local_artifacts[1]:
                raise ValueError(
                    "reviewed local valve model and manifest paths must differ"
                )
        if self.capture_run_enabled and self.deployment_mode != "workcell":
            raise ValueError(
                "LABCOBOT_CAPTURE_RUN_ENABLED=true requires workcell deployment"
            )
        if self.deployment_mode == "workcell" and not self.capture_run_enabled:
            raise ValueError(
                "workcell deployment requires LABCOBOT_CAPTURE_RUN_ENABLED=true"
            )
        if self.capture_run_enabled:
            missing_capture = [
                name
                for name, value in (
                    (
                        "LABCOBOT_CAPTURE_DATASET_ROOT",
                        self.capture_dataset_root,
                    ),
                    (
                        "LABCOBOT_CAPTURE_MOTION_PROFILE_PATH",
                        self.capture_motion_profile_path,
                    ),
                    (
                        "LABCOBOT_CAPTURE_MOTION_PROFILE_SHA256",
                        self.capture_motion_profile_sha256,
                    ),
                    (
                        "LABCOBOT_CAPTURE_SAFETY_EVIDENCE_PATH",
                        self.capture_safety_evidence_path,
                    ),
                    (
                        "LABCOBOT_CAPTURE_SAFETY_EVIDENCE_SHA256",
                        self.capture_safety_evidence_sha256,
                    ),
                    (
                        "LABCOBOT_CAPTURE_WORKSPACE_LOWER_XYZ_MM",
                        self.capture_workspace_lower_xyz_mm,
                    ),
                    (
                        "LABCOBOT_CAPTURE_WORKSPACE_UPPER_XYZ_MM",
                        self.capture_workspace_upper_xyz_mm,
                    ),
                    (
                        "LABCOBOT_CAPTURE_JOINT_LIMITS_POLICY",
                        self.capture_joint_limits_policy,
                    ),
                )
                if not value
            ]
            if missing_capture:
                raise ValueError(
                    "capture-run configuration is incomplete: missing {}".format(
                        ", ".join(missing_capture)
                    )
                )
            if (
                not self.cameras_enabled
                or self.tool_camera_backend != "depthai"
                or self.workspace_camera_backend != "depthai"
                or not self.mjpeg_enabled
                or self.mjpeg_host not in {"127.0.0.1", "::1", "localhost"}
            ):
                raise ValueError(
                    "capture-run workcell requires loopback MJPEG and two "
                    "DepthAI cameras"
                )
            if (
                self.tool_camera_source_id != self.tool_camera_device_id
                or self.workspace_camera_source_id != self.workspace_camera_device_id
            ):
                raise ValueError(
                    "capture-run camera source IDs must equal their OAK MXIDs"
                )
            if (
                not self.xarm_metrics_enabled
                or not self.xarm_ip
                or self.xarm_model != "lite6"
            ):
                raise ValueError(
                    "capture-run workcell requires Lite6 telemetry and an xArm IP"
                )
            if not self.datahub_configured or self.datahub_dataset_asset_id <= 0:
                raise ValueError(
                    "capture-run workcell requires a provisioned DataHub "
                    "ingest URL and LABCOBOT_DATAHUB_DATASET_ASSET_ID"
                )
            if (
                self.xarm_motion_enabled
                or self.dynamic_valve_enabled
                or self.eye_in_hand_enabled
            ):
                raise ValueError(
                    "capture-run workcell forbids legacy, valve, and eye-in-hand "
                    "motion owners"
                )
            if self.capture_joint_limits_policy != "xarm-lite6-conservative-v1":
                raise ValueError(
                    "LABCOBOT_CAPTURE_JOINT_LIMITS_POLICY must be "
                    "xarm-lite6-conservative-v1"
                )
            for name, digest in (
                (
                    "LABCOBOT_CAPTURE_MOTION_PROFILE_SHA256",
                    self.capture_motion_profile_sha256,
                ),
                (
                    "LABCOBOT_CAPTURE_SAFETY_EVIDENCE_SHA256",
                    self.capture_safety_evidence_sha256,
                ),
            ):
                if not re.fullmatch(r"[0-9a-f]{64}", digest):
                    raise ValueError(
                        "{} must contain exactly 64 lowercase hexadecimal "
                        "characters".format(name)
                    )
            dataset_root = Path(self.capture_dataset_root)
            state_root = Path(self.state_dir)
            if (
                not dataset_root.is_absolute()
                or ".." in dataset_root.parts
                or not state_root.is_absolute()
            ):
                raise ValueError(
                    "LABCOBOT_CAPTURE_DATASET_ROOT must be an absolute safe path"
                )
            try:
                relative_dataset = dataset_root.relative_to(state_root)
            except ValueError as exc:
                raise ValueError(
                    "LABCOBOT_CAPTURE_DATASET_ROOT must be below LABCOBOT_STATE_DIR"
                ) from exc
            if not relative_dataset.parts:
                raise ValueError(
                    "LABCOBOT_CAPTURE_DATASET_ROOT must be below LABCOBOT_STATE_DIR"
                )
            artifact_root = Path("/etc/labcobot")
            for name, raw_path in (
                (
                    "LABCOBOT_CAPTURE_MOTION_PROFILE_PATH",
                    self.capture_motion_profile_path,
                ),
                (
                    "LABCOBOT_CAPTURE_SAFETY_EVIDENCE_PATH",
                    self.capture_safety_evidence_path,
                ),
            ):
                artifact = Path(raw_path)
                try:
                    relative_artifact = artifact.relative_to(artifact_root)
                except ValueError as exc:
                    raise ValueError(
                        "{} must be below /etc/labcobot".format(name)
                    ) from exc
                if (
                    not artifact.is_absolute()
                    or ".." in artifact.parts
                    or not relative_artifact.parts
                ):
                    raise ValueError("{} path is unsafe".format(name))
            if (
                len(self.capture_workspace_lower_xyz_mm) != 3
                or len(self.capture_workspace_upper_xyz_mm) != 3
                or any(
                    low >= high
                    for low, high in zip(
                        self.capture_workspace_lower_xyz_mm,
                        self.capture_workspace_upper_xyz_mm,
                    )
                )
            ):
                raise ValueError(
                    "capture workspace lower bounds must be below upper bounds"
                )
        if self.deployment_mode == "gateway" and self.xarm_motion_enabled:
            raise ValueError(
                "gateway deployment forbids LABCOBOT_XARM_MOTION_ENABLED=true"
            )
        if self.xarm_motion_enabled:
            if self.xarm_model != "lite6":
                raise ValueError(
                    "live motion currently requires "
                    "LABCOBOT_XARM_MODEL=lite6 so joint limits are explicit"
                )
            missing = []
            if not self.dynamic_valve_enabled:
                missing.append("LABCOBOT_DYNAMIC_VALVE_ENABLED")
            if not self.xarm_metrics_enabled:
                missing.append("LABCOBOT_XARM_METRICS_ENABLED")
            if not self.dynamic_valve_primitive_path:
                missing.append("LABCOBOT_DYNAMIC_VALVE_PRIMITIVE_PATH")
            if not self.dynamic_valve_primitive_sha256:
                missing.append("LABCOBOT_DYNAMIC_VALVE_PRIMITIVE_SHA256")
            if not self.eye_in_hand_calibration_sha256:
                missing.append("LABCOBOT_EYE_IN_HAND_CALIBRATION_SHA256")
            if not self.safety_profile_path:
                missing.append("LABCOBOT_SAFETY_PROFILE_PATH")
            if not self.safety_profile_sha256:
                missing.append("LABCOBOT_SAFETY_PROFILE_SHA256")
            if missing:
                raise ValueError(
                    "xArm motion configuration is incomplete: missing {}".format(
                        ", ".join(missing)
                    )
                )
            if len(self.dynamic_valve_primitive_sha256) != 64 or any(
                character not in "0123456789abcdef"
                for character in self.dynamic_valve_primitive_sha256
            ):
                raise ValueError(
                    "LABCOBOT_DYNAMIC_VALVE_PRIMITIVE_SHA256 must contain "
                    "exactly 64 hexadecimal characters"
                )
            if len(self.eye_in_hand_calibration_sha256) != 64 or any(
                character not in "0123456789abcdef"
                for character in self.eye_in_hand_calibration_sha256
            ):
                raise ValueError(
                    "LABCOBOT_EYE_IN_HAND_CALIBRATION_SHA256 must contain "
                    "exactly 64 hexadecimal characters"
                )
        if self.deployment_mode == "production":
            required_features = (
                ("registered-device readiness", self.registry_configured),
                ("DataHub time-series", self.datahub_configured),
                ("signed OTA", self.ota_configured),
                ("ShortcutJob compute offload", self.offload_configured),
                ("dual cameras", self.cameras_enabled),
                ("dual-camera MJPEG streaming", self.mjpeg_enabled),
                ("xArm telemetry", self.xarm_metrics_enabled),
                ("eye-in-hand calibration", self.eye_in_hand_enabled),
                ("dynamic valve discovery", self.dynamic_valve_enabled),
                ("reviewed live xArm motion", self.xarm_motion_enabled),
            )
            missing_features = [
                name for name, configured in required_features if not configured
            ]
            if missing_features:
                raise ValueError(
                    "production deployment is incomplete: missing {}".format(
                        ", ".join(missing_features)
                    )
                )
        if self.deployment_mode == "operator":
            if self.offload_configured == self.local_valve_model_configured:
                raise ValueError(
                    "operator deployment requires exactly one reviewed valve "
                    "confirmation owner: compute offload or local model"
                )
            required_features = (
                ("dual cameras", self.cameras_enabled),
                ("dual-camera MJPEG streaming", self.mjpeg_enabled),
                ("xArm telemetry", self.xarm_metrics_enabled),
                ("eye-in-hand calibration", self.eye_in_hand_enabled),
                ("dynamic valve discovery", self.dynamic_valve_enabled),
                ("reviewed live xArm motion", self.xarm_motion_enabled),
            )
            missing_features = [
                name for name, configured in required_features if not configured
            ]
            if missing_features:
                raise ValueError(
                    "operator deployment is incomplete: missing {}".format(
                        ", ".join(missing_features)
                    )
                )
        if self.deployment_mode == "gateway":
            required_features = (
                ("dual cameras", self.cameras_enabled),
                ("dual-camera MJPEG streaming", self.mjpeg_enabled),
                ("xArm telemetry", self.xarm_metrics_enabled),
            )
            missing_features = [
                name for name, configured in required_features if not configured
            ]
            if missing_features:
                raise ValueError(
                    "gateway deployment is incomplete: missing {}".format(
                        ", ".join(missing_features)
                    )
                )

    def build_datahub_publisher(self) -> Optional[DataHubPublisher]:
        if not self.datahub_configured:
            return None
        outbox = DurableOutbox(self.state_dir / "datahub.sqlite3")
        return DataHubPublisher(
            ingest_url=self.datahub_ingest_url,
            uid_device=self.device_uid,
            api_secret=self.device_secret,
            manufacturer_id=self.manufacturer_id,
            outbox=outbox,
            expected_dataset_asset_id=self.datahub_dataset_asset_id,
        )

    def build_readiness_client(self) -> Optional[DeviceReadinessClient]:
        if not self.registry_configured:
            return None
        return DeviceReadinessClient(
            readiness_url=self.device_readiness_url,
            uid_device=self.device_uid,
            api_secret=self.device_secret,
            manufacturer_id=self.manufacturer_id,
        )

    def build_ota_updater(self) -> Optional[object]:
        if not self.ota_configured:
            return None
        if self.immutable_release:
            return PrivilegedOTAClient(
                request_dir=Path(self.ota_privileged_request_dir),
                status_path=Path(self.ota_privileged_status_path),
                current_version=self.current_version,
            )
        return SecureOTAUpdater(
            metadata_url=self.ota_manifest_url,
            report_url=self.ota_report_url,
            uid_device=self.device_uid,
            api_secret=self.device_secret,
            signing_key=self.ota_signing_key,
            install_root=self.install_root,
            current_version=self.current_version,
            state_dir=self.state_dir / "ota",
            allowed_download_hosts=self.ota_allowed_download_hosts,
        )

    def build_offload_client(
        self,
        bearer_token_provider: Optional[Callable[..., str]] = None,
        endpoint_url_provider: Optional[Callable[[str], str]] = None,
    ) -> Optional[ShortcutJobClient]:
        if not self.offload_configured:
            return None
        return ShortcutJobClient(
            submit_url=self.offload_submit_url,
            status_url=self.offload_status_url,
            shortcut_id=self.offload_shortcut_id,
            shortcut_ids={
                "trajectory_training": (
                    self.offload_trajectory_shortcut_id or self.offload_shortcut_id
                ),
                "valve_detection": (
                    self.offload_valve_shortcut_id or self.offload_shortcut_id
                ),
            },
            api_key=self.offload_api_key,
            bearer_token=self.offload_bearer_token,
            bearer_token_provider=bearer_token_provider,
            endpoint_url_provider=endpoint_url_provider,
            device_uid=self.device_uid,
            device_secret=self.device_secret,
            submit_mode=self.offload_submit_mode,
        )

    def build_hand_training_uploader(
        self,
        bearer_token_provider: Optional[Callable[..., str]] = None,
        endpoint_url_provider: Optional[Callable[[str], str]] = None,
    ):
        """Build the tenant-bound streaming upload client for two video tracks."""

        if not self.hand_training_configured:
            return None
        try:
            from main.integrations.hand_training_upload import HandTrainingUploadClient
        except ImportError:  # Direct ``main/runtime.py`` execution on the Pi.
            from integrations.hand_training_upload import (  # type: ignore[no-redef]
                HandTrainingUploadClient,
            )
        return HandTrainingUploadClient(
            upload_url=self.hand_training_upload_url,
            device_uid=self.device_uid,
            device_secret=self.device_secret,
            bearer_token_provider=bearer_token_provider,
            endpoint_url_provider=endpoint_url_provider,
            maximum_upload_bytes=(self.hand_training_max_video_bytes * 2)
            + (16 * 1024 * 1024),
            allow_insecure_http=self.deployment_mode == "development",
        )


def _decode_signing_key(encoded: str) -> bytes:
    if not encoded:
        return b""
    try:
        value = base64.b64decode(encoded, validate=True)
    except (binascii.Error, ValueError) as exc:
        raise ValueError("LABCOBOT_OTA_SIGNING_KEY_B64 must be valid base64") from exc
    if len(value) < 32:
        raise ValueError(
            "LABCOBOT_OTA_SIGNING_KEY_B64 must decode to at least 32 bytes"
        )
    return value


def parse_camera_source(value: str):
    """Convert a decimal V4L2 index to int while preserving URLs/paths."""

    cleaned = str(value).strip()
    if cleaned.isdecimal():
        return int(cleaned)
    return cleaned


__all__ = ["IntegrationSettings", "parse_camera_source"]
