// src/main.ts
/// <reference types="@iobroker/adapter-core" />

import * as utils from "@iobroker/adapter-core";
import { ChildProcess, spawn } from "node:child_process";
import { randomBytes } from "node:crypto";
import go2rtcPath from "go2rtc-static";
import { commitInfo } from "./lib/commitInfo";

// --- API & Helper Imports ---
import { AppPluginManager } from "./lib/AppPluginManager";
import { B01Variant, getB01VariantFromModel } from "./lib/b01Variant";
import { DeviceManager } from "./lib/deviceManager";
import { BaseDeviceFeatures } from "./lib/features/baseDeviceFeatures";
import { Feature } from "./lib/features/features.enum";

import { Device, http_api } from "./lib/httpApi";
import { local_api } from "./lib/localApi";
import { MapManager } from "./lib/map/MapManager";
import { mqtt_api } from "./lib/mqttApi";
import { PendingMapEntry, RequestPriority, RoborockRequest, requestsHandler } from "./lib/requestsHandler";
import { socketHandler } from "./lib/socketHandler";
import { TranslationManager } from "./lib/translationManager";

interface SentryPlugin {
	getSentryObject(): {
		captureException(error: unknown): void;
	};
}

export class Roborock extends utils.Adapter {
	// --- Public APIs (accessible by helpers) ---
	public http_api: http_api;
	public local_api: local_api;
	public mqtt_api: mqtt_api;
	public requestsHandler: requestsHandler;
	public socketHandler!: socketHandler;
	public deviceManager!: DeviceManager;
	public mapManager: MapManager;
	public translationManager!: TranslationManager;

	// --- Internal Properties ---
	public deviceFeatureHandlers: Map<string, BaseDeviceFeatures>;
	public nonce: Buffer;
	public pendingRequests: Map<number, RoborockRequest | PendingMapEntry>;
	/** B01: FIFO queue of expected 301 map response types (classify + taskBeginDate match using this order). */
	public b01MapResponseQueue: Map<string, Array<"get_map_v1" | "get_clean_record_map">> = new Map();
	public appPluginManager: AppPluginManager;

	public isInitializing: boolean;
	public sentryInstance: SentryPlugin | undefined;
	public translations: Record<string, string> = {};

	private commandTimeouts: Map<string, ioBroker.Timeout> = new Map();
	private mqttReconnectInterval: ioBroker.Interval | undefined = undefined;
	public instance: number = 0;
	private go2rtcProcess: ChildProcess | null = null;
	// Bound exit handler to prevent memory leaks while allowing process.removeListener
	private onExitBound: (() => void) | null = null;

	constructor(options: Partial<utils.AdapterOptions> = {}) {
		super({ ...options, name: "roborock", useFormatDate: true });

		this.instance = options.instance || 0;
		this.nonce = randomBytes(16);
		this.pendingRequests = new Map();
		this.http_api = new http_api(this);
		this.local_api = new local_api(this);
		this.mqtt_api = new mqtt_api(this);
		this.requestsHandler = new requestsHandler(this);
		this.mapManager = new MapManager(this);
		this.translationManager = new TranslationManager(this);

		this.deviceManager = new DeviceManager(this);
		this.socketHandler = new socketHandler(this);
		this.deviceFeatureHandlers = this.deviceManager.deviceFeatureHandlers;

		this.appPluginManager = new AppPluginManager(this);

		this.isInitializing = true;

		this.on("ready", this.onReady.bind(this));
		this.on("stateChange", this.onStateChange.bind(this));
		this.on("message", this.onMessage.bind(this));
		this.on("unload", this.onUnload.bind(this));

		// Global Error Handlers
		process.on("uncaughtException", (err) => {
			this.rLog("System", null, "Error", undefined, undefined, `Uncaught Exception: ${err.message}\n${err.stack}`, "error");
		});

		process.on("unhandledRejection", (reason) => {
			this.rLog("System", null, "Error", undefined, undefined, `Unhandled Rejection: ${reason}`, "error");
		});
	}

	/**
	 * Adapter ready logic.
	 */
	async onReady() {
		// Config properties are now type-safe thanks to types.d.ts
		if (!this.config.username) {
			this.rLog("System", null, "Error", undefined, undefined, "Username missing!", "error");
			this.isInitializing = false;
			return;
		}

		this.translationManager.init();

		this.sentryInstance = this.getPluginInstance("sentry") as SentryPlugin | undefined;
		this.translations = require(`../admin/i18n/${this.language || "en"}/translations.json`);

		this.rLog("System", null, "Info", undefined, undefined, `Build Info: Date=${commitInfo.commitDate}, Commit=${commitInfo.commitHash}`, "debug");

		// Log adapter settings at start (no credentials) for easier support/debugging
		const safeSettings: Record<string, unknown> = {
			enable_map_creation: this.config.enable_map_creation,
			updateInterval: this.config.updateInterval,
			region: this.config.region,
			loginMethod: this.config.loginMethod,
			map_theme: this.config.map_theme,
		};
		if ("map_creation_interval" in this.config) safeSettings.map_creation_interval = (this.config as Record<string, unknown>).map_creation_interval;
		if ("map_scale" in this.config) safeSettings.map_scale = (this.config as Record<string, unknown>).map_scale;
		if ("webserverPort" in this.config) safeSettings.webserverPort = (this.config as Record<string, unknown>).webserverPort;
		this.rLog("System", null, "Info", undefined, undefined, `Settings: ${JSON.stringify(safeSettings)}`, "info");

		// Full config for debug (credentials redacted)
		const configSummary = {
			...this.config,
			username: this.config.username ? "******" : "NOT_SET",
			password: this.config.password ? "******" : "NOT_SET",
			cameraPin: this.config.cameraPin ? "******" : undefined,
		};
		this.rLog("System", null, "Info", undefined, undefined, `Config: ${JSON.stringify(configSummary)}`, "debug");

		await this.setupBasicObjects();

		try {
			const clientID = await this.ensureClientID();
			await this.http_api.init(clientID);

			// 1. Start Cloud Data Sync (Get Keys & DUIDs)
			await this.http_api.updateHomeData();

			// 1b. Asset download for account models (before device init)
			await this.downloadAssetsForAccountModels();

			// 2a. Start UDP Discovery (Essential for determining Local/Cloud mode before Init)
			await this.local_api.startUdpDiscovery();

			// 2b. Start MQTT and WAIT for the connection to be established
			await this.mqtt_api.init();

			// --- Pre-Init Network Probe (Docker/VLAN Support) ---
			this.rLog("System", null, "Info", undefined, undefined, "Starting Pre-Init Network Probe...", "debug");
			const allDevices = this.http_api.getDevices() || [];
			const probePromises = allDevices.map(async (device) => {
				const duid = device.duid;
				if (!device.online) return; // Skip devices cloud reports as offline
				// If already local (UDP found it), skip
				if (this.local_api.isConnected(duid)) return;
				const protocolVersion = device.pv || await this.getDeviceProtocolVersion(duid);
				if (protocolVersion === "B01") {
					const model = this.http_api.getRobotModel(duid) || "";
					if (model && getB01VariantFromModel(model) === "Q10") {
						return;
					}
				}

				try {
					// 1. Get Network Info (via MQTT as we have no TCP yet)
					const result = await this.requestsHandler.sendRequest(duid, "get_network_info", []);

					// 2. Extract IP
					let networkData: Record<string, unknown> | undefined;
					if (Array.isArray(result)) {
						networkData = result[0] as Record<string, unknown>;
					} else if (result && typeof result === "object") {
						networkData = result as Record<string, unknown>;
					}

					if (networkData && typeof networkData.ip === "string") {
						// 3. Attempt TCP Connect with short timeout (1.5s) and silent logging
						await this.local_api.checkAndPromoteLocalConnection(duid, networkData.ip, 1500, true);
					}
				} catch (e: unknown) {
					const errorMsg = e instanceof Error ? e.message : String(e);
					this.rLog("System", duid, "Debug", undefined, undefined, `Probe failed: ${errorMsg}`, "debug");
				}
			});

			// Wait for all probes to finish (with timeout to not block forever)
			await Promise.race([
				Promise.all(probePromises),
				new Promise(resolve => setTimeout(resolve, 2000)) // Max 2s probe time
			]);
			this.rLog("System", null, "Info", undefined, undefined, "Network Probe finished.", "info");
			// ----------------------------------------------------

			// 3. Initialize Devices (now that communication channels are ready)
			await this.deviceManager.initializeDevices();

			const writableFolders = new Set<string>();
			for (const handler of this.deviceFeatureHandlers.values()) {
				for (const folder of handler.getCommandFolders()) {
					writableFolders.add(folder);
				}
			}

			// Parallelize non-dependent startup tasks
			await Promise.all([
				this.processScenes(),
				this.start_go2rtc(),
				...Array.from(writableFolders).map((folder) => this.subscribeStatesAsync(`Devices.*.${folder}.*`)),
				this.subscribeStatesAsync("Devices.*.resetConsumables.*"),
				this.subscribeStatesAsync("Devices.*.programs.*"),
				this.subscribeStatesAsync("Devices.*.deviceStatus.state"),
				this.subscribeStatesAsync("Devices.*.deviceStatus.status"),
				this.subscribeStatesAsync("loginCode")
			]);

			this.deviceManager.startPolling();
			this.local_api.startTcpKeepaliveInterval();

			this.rLog("System", null, "Info", undefined, undefined, "Adapter startup finished. Let's go!", "info");
			this.isInitializing = false;

			// Schedule MQTT API reset every hour (legacy behavior to prevent stale connections)
			this.mqttReconnectInterval = this.setInterval(() => {
				this.rLog("System", null, "Debug", undefined, undefined, "Running scheduled MQTT reconnect...", "debug");
				this.resetMqttApi().catch((e: unknown) => {
					this.rLog("System", null, "Error", undefined, undefined, `Scheduled MQTT reconnect failed: ${e instanceof Error ? e.message : String(e)}`, "error");
					this.catchError(e, "resetMqttApi (scheduled)");
				});
			}, 3600 * 1000);
		} catch (e: unknown) {
			this.rLog("System", null, "Error", undefined, undefined, `Failed to initialize adapter: ${this.errorMessage(e)}`, "error");
			this.catchError(e, "onReady");
			this.isInitializing = false;
		}
	}

	/**
	 * Message handler for Admin/Vis communication.
	 */
	async onMessage(obj: ioBroker.Message) {
		if (obj && obj.command && obj.callback) {
			try {
				// Forward to the dedicated handler
				await this.socketHandler.handleMessage(obj);
			} catch (err: unknown) {
				this.rLog("Requests", null, "Error", undefined, undefined, `Failed to execute command ${obj.command}: ${this.errorMessage(err)}`, "error");
				this.sendTo(obj.from, obj.command, { error: this.errorMessage(err) }, obj.callback);
			}
		}
	}

	/**
	 * Executes a scene locally by parsing the scene definition and sending commands to the device.
	 */
	async executeSceneLocal(sceneId: string | number): Promise<void> {
		try {
			this.rLog("Requests", null, "Info", undefined, undefined, `[Scene] Executing local scene ${sceneId}`, "info");

			// 1. Fetch scenes
			const scenes = await this.http_api.getScenes();
			if (!scenes || !scenes.result) {
				this.rLog("Requests", null, "Error", undefined, undefined, `[Scene] Failed to fetch scenes or no result for ${sceneId}`, "error");
				return;
			}

			// 2. Find target scene
			// Scene ID from state might be string, API returns number. Compare loosely or convert.
			const scene = scenes.result.find((s) => s.id == sceneId);

			if (!scene) {
				this.rLog("Requests", null, "Error", undefined, undefined, `[Scene] Scene ${sceneId} not found`, "error");
				return;
			}

			this.rLog("Requests", null, "Debug", undefined, undefined, `[Scene] Found scene "${scene.name}"`, "debug");

			// 3. Parse 'param' field
			let params;
			try {
				params = JSON.parse(scene.param);
			} catch (e: unknown) {
				this.rLog("Requests", null, "Error", undefined, undefined, `[Scene] Failed to parse params for ${sceneId}: ${this.errorMessage(e)}`, "error");
				return;
			}

			// 4. Iterate actions and execute
			if (params.action && params.action.items) {
				for (const item of params.action.items) {
					if (item.type === "CMD") {
						const targetDuid = item.entityId;
						let commandPayload;

						try {
							commandPayload = JSON.parse(item.param);
						} catch (e: unknown) {
							this.rLog("Requests", targetDuid, "Error", undefined, undefined, `[Scene] Failed to parse command params for item ${item.id}: ${this.errorMessage(e)}`, "error");
							continue;
						}

						const method = commandPayload.method;
						const args = commandPayload.params;

						this.rLog("Requests", targetDuid, "Info", undefined, undefined, `[Scene] Executing "${scene.name}": sending "${method}"`, "info");

						// 5. Send command via requestsHandler
						// We pass 'null' as handler because we are sending a raw command directly via specific method/args
						// and don't need the abstraction of 'BaseDeviceFeatures' here if we go direct.
						// However, requestsHandler.command expects a handler.
						// Let's resolve the handler for the target Duid if possible, or cast/hack if needed.
						const handler = this.deviceFeatureHandlers.get(targetDuid);

						if (handler) {
							await this.requestsHandler.command(handler, targetDuid, method, args);
						} else {
							this.rLog("Requests", targetDuid, "Warn", undefined, undefined, `[Scene] No handler found. Falling back to raw send for "${method}"`, "warn");
							// Fallback: sendRequest only. Status refresh after activity-start is still triggered in resolvePendingRequest when response arrives.
							await this.requestsHandler.sendRequest(targetDuid, method, args);
						}
					}
				}
			} else {
				this.rLog("Requests", null, "Warn", undefined, undefined, `[Scene] Scene ${sceneId} has no actions`, "warn");
			}
		} catch (e: unknown) {
			this.rLog("Requests", null, "Error", undefined, undefined, `[Scene] Error executing ${sceneId}: ${this.errorMessage(e)}`, "error");
		}
	}

	/** Legacy request-based keepalive. TCP socket sessions now use localApi PINGREQ frames. */
	sendTcpKeepalive(duid: string): void {
		this.requestsHandler.sendRequest(duid, "get_prop", ["get_status"], { priority: RequestPriority.LOW }).catch(() => {});
	}

	/**
	 * Is called when adapter shuts down.
	 */
	onUnload(callback: () => void) {
		try {
			if (this.mqttReconnectInterval) {
				this.clearInterval(this.mqttReconnectInterval);
			}
			this.clearTimersAndIntervals();
			this.mqtt_api.cleanup();
			this.local_api.stopUdpDiscovery();
			this.local_api.stopTcpKeepaliveInterval();

			// Remove the global process exit listener to prevent memory leaks
			if (this.onExitBound) {
				process.removeListener("exit", this.onExitBound);
				this.onExitBound = null;
			}

			if (this.go2rtcProcess) {
				this.rLog("Local", null, "Info", undefined, undefined, "Stopping go2rtc process...", "info");
				this.go2rtcProcess.kill();
				this.go2rtcProcess = null;
			}
			this.setState("info.connection", { val: false, ack: true });
			callback();
		} catch (e: unknown) {
			this.rLog("System", null, "Error", undefined, undefined, `Failed to unload adapter: ${this.errorStack(e)}`, "error");
			callback();
		}
	}

	/**
	 * Is called if a subscribed state changes.
	 */
	async onStateChange(id: string, state: ioBroker.State | null | undefined) {
		if (!state) return;

		const idParts = id.split(".");

		// deviceStatus.state (V1) or deviceStatus.status (B01): react only to our own updates (ack) — active -> idle triggers cleaning records update
		if (state.ack && idParts[2] === "Devices" && idParts.length >= 6 && idParts[4] === "deviceStatus" && (idParts[5] === "state" || idParts[5] === "status")) {
			const duid = idParts[3];
			const newVal = state.val != null ? Number(state.val) : 0;
			if (!isNaN(newVal)) {
				this.deviceManager.onDeviceStateChange(duid, newVal).catch((e: unknown) => this.catchError(e, "onStateChange(deviceStatus)", duid));
			}
			return;
		}

		if (state.ack) {
			if (id.endsWith(".online") && idParts.length >= 4) {
				this.rLog("System", idParts[3], "Info", undefined, undefined, `Device is now ${state.val ? "online" : "offline"}`, "info");
			}
			return;
		}

		// Check for root loginCode (roborock.0.loginCode)
		if (idParts[2] === "loginCode" && state.val && String(state.val).length === 6) {
			this.http_api.submitLoginCode(String(state.val));
			return;
		}

		// Devices logic
		if (idParts[2] !== "Devices") return;
		if (idParts.length < 6) return;

		const duid = idParts[3];
		const folder = idParts[4];
		const command = idParts[5];

		// Special handling for floors (deeply nested: Devices.duid.floors.mapFlag.load)
		if (folder === "floors" && idParts.length >= 7) {
			const mapFlag = parseInt(idParts[5], 10);
			const target = idParts[6];

			// Load Map Button
			if (target === "load" && (state.val === true || state.val === "true" || state.val === 1)) {
				await this.handleFloorSwitch(duid, mapFlag, id);
				return;
			}
		}
		this.rLog("Requests", duid, "Info", undefined, undefined, `[onStateChange] Processing ${folder}.${command}`, "info");

		const handler = this.deviceFeatureHandlers.get(duid);
		if (!handler) {
			this.rLog("Requests", duid, "Warn", undefined, undefined, "[onStateChange] Received command for unknown device", "warn");
			return;
		}

		try {
			await this.handleCommand(duid, folder, command, state, handler, id);
		} catch (e: unknown) {
			this.catchError(e, `onStateChange (${command})`, duid);
		}
	}

	/**
	 * Handles commands from onStateChange.
	 */
	private async handleCommand(duid: string, folder: string, command: string, state: ioBroker.State, handler: BaseDeviceFeatures, id: string) {
		if (folder === "resetConsumables" && state.val === true) {
			await this.requestsHandler.command(handler, duid, "reset_consumable", command, id);
			// Reset button
			this.setResetTimeout(id);
		} else if (folder === "programs" && command === "startProgram") {
			await this.executeSceneLocal(state.val as string);
			this.setResetTimeout(id); // Use setResetTimeout to reset to null/empty after 1s?
			// Actually executeSceneLocal takes time.
			// Better: explicit reset.
			await this.setState(id, { val: null, ack: true });
		} else if (handler.hasCommandFolder(folder)) {
			this.rLog("Requests", duid, "Info", handler.protocolVersion || undefined, undefined, `[handleCommand] Entering commands block for ${command}`, "info");
			try {
				await this.executeCommand(handler, duid, folder, command, state);
			} finally {
				// Reset boolean command state ONLY if it is defined as boolean
				const cmdDef = handler.getCommandSpec(folder, command);
				const isBoolean = cmdDef && cmdDef.type === "boolean";

				if (isBoolean && this.isTruthy(state.val)) {
					this.rLog("Requests", duid, "Info", handler.protocolVersion || undefined, undefined, `[handleCommand] Scheduling reset for ${id} (boolean)`, "info");
					this.setResetTimeout(id);
				}
			}
		}
	}

	/**
	 * Executes a specific command for a device.
	 */
	private async executeCommand(handler: BaseDeviceFeatures, duid: string, folder: string, command: string, state: ioBroker.State) {
		const val = state.val;

		// 1. Common command types handling
		const cmdDef = handler.getCommandSpec(folder, command);
		const isButton = cmdDef?.role === "button" || cmdDef?.type === "boolean";

		if (isButton) {
			if (this.isTruthy(val)) {
				this.rLog("Requests", duid, "Info", handler.protocolVersion || undefined, undefined, `[executeCommand] Triggering button command ${command}`, "info");
				await this.requestsHandler.command(handler, duid, command);
			} else {
				this.rLog("Requests", duid, "Debug", handler.protocolVersion || undefined, undefined, `[executeCommand] Ignoring button command ${command} (val=${val})`, "debug");
			}
			return;
		}

		// Log start of command execution for diagnostics
		this.rLog("Requests", duid, "Info", handler.protocolVersion || undefined, undefined, `[executeCommand] Starting ${command} with params ${typeof val === "object" ? JSON.stringify(val) : val}`, "info");

		// 2. Generic data commands (Numbers, Strings, JSON strings)
		// We pass the raw value. getCommandParams in feature handlers will do the packaging (e.g. [val]).
		if (typeof val === "string") {
			const parsed = this.tryParseJson(val);
			await this.requestsHandler.command(handler, duid, command, parsed !== undefined ? parsed : val);
		} else {
			await this.requestsHandler.command(handler, duid, command, val);
		}
	}

	private isTruthy(val: unknown): boolean {
		return val === true || val === "true" || val === 1 || val === "1";
	}

	/**
	 * Sets a timeout to reset a state to false after 1 second.
	 * Helps avoid race conditions by managing timeouts in a map.
	 */
	private setResetTimeout(id: string): void {
		const timeoutKey = `${id}_reset`;
		if (this.commandTimeouts.has(timeoutKey)) {
			this.clearTimeout(this.commandTimeouts.get(timeoutKey)!);
		}
		const timeout = this.setTimeout(() => {
			this.rLog("Requests", null, "Debug", undefined, undefined, `[setResetTimeout] Resetting ${id} to false`, "debug");
			this.setState(id, false, true);
			this.commandTimeouts.delete(timeoutKey);
		}, 1000);
		if (timeout) this.commandTimeouts.set(timeoutKey, timeout);
	}

	/**
	 * Ensures a ClientID exists.
	 */
	async ensureClientID(): Promise<string> {
		try {
			const clientIDState = await this.getStateAsync("clientID"); // Revert to Async
			if (clientIDState?.val) {
				this.rLog("System", null, "Info", undefined, undefined, `Loaded existing clientID: ${clientIDState.val}`, "info");
				return clientIDState.val.toString();
			}
			const randomClientID = randomBytes(16).toString("hex");
			await this.setState("clientID", { val: randomClientID, ack: true });
			this.rLog("System", null, "Info", undefined, undefined, `Generated and saved new clientID: ${randomClientID}`, "info");
			return randomClientID;
		} catch (error: unknown) {
			const errorMsg = error instanceof Error ? error.message : String(error);
			this.rLog("System", null, "Error", undefined, undefined, `Error ensuring clientID: ${errorMsg}`, "error");
			throw error;
		}
	}

	/**
	 * Creates base adapter objects (Folders, States).
	 */
	async setupBasicObjects() {
		await this.setObjectNotExistsAsync("Devices", { type: "folder", common: { name: "Devices" }, native: {} });
		await this.ensureState("UserData", { name: "UserData string", write: false });
		await this.ensureState("HomeData", { name: "HomeData string", write: false });
		await this.ensureState("clientID", { name: "Client ID", write: false });
		await this.ensureState("endpoint", { name: "MQTT endpoint", write: false });
	}

	/** Obstacle assets for account models at startup (before device init). */
	private async downloadAssetsForAccountModels(): Promise<void> {
		try {
			await this.http_api.ensureProductInfo();
			let devices = this.http_api.getDevices() || [];
			for (let wait = 0; wait < 6 && devices.length === 0; wait++) {
				await new Promise((r) => setTimeout(r, 500));
				devices = this.http_api.getDevices() || [];
			}
			const modelsInAccount = new Set<string>();
			for (const d of devices) {
				const m = this.http_api.getRobotModel(d.duid);
				if (m && m !== "unknown" && m.includes(".")) modelsInAccount.add(m);
			}
			if (modelsInAccount.size === 0) return;
			this.rLog("System", null, "Info", undefined, undefined, `Downloading obstacle assets for ${modelsInAccount.size} model(s)...`, "info");
			await this.http_api.downloadProductImages();
			for (const model of modelsInAccount) {
				await this.appPluginManager.downloadAssetsForModelIfMissing(model).catch((e: unknown) => {
					this.rLog("Cloud", null, "Debug", undefined, undefined, `Asset download for ${model}: ${e instanceof Error ? e.message : String(e)}`, "debug");
				});
			}
		} catch (e: unknown) {
			this.rLog("System", null, "Warn", undefined, undefined, `Obstacle asset download failed: ${e instanceof Error ? e.message : String(e)}`, "warn");
		}
	}

	/**
	 * Processes scenes from HTTP API.
	 */
	async processScenes() {
		const scenes = await this.http_api.getScenes();
		if (!scenes?.result) return;

		const data = scenes.result;
		const programs: Record<string, Record<string, string>> = {};

		for (const program of data) {
			try {
				const { enabled, id, name, param } = program;
				const params = JSON.parse(param);
				const duid = params.action.items[0].entityId;

				if (!programs[duid]) programs[duid] = {};
				programs[duid][id] = name;

				await this.ensureFolder(`Devices.${duid}.programs`);
				await this.setObjectNotExistsAsync(`Devices.${duid}.programs.${id}`, {
					type: "folder",
					common: { name },
					native: {},
				});

				await this.ensureState(`Devices.${duid}.programs.${id}.enabled`, { name: "Enabled", type: "boolean" });
				this.setState(`Devices.${duid}.programs.${id}.enabled`, enabled, true);
			} catch (e: unknown) {
				const errorMsg = e instanceof Error ? e.message : String(e);
				this.rLog("Requests", null, "Warn", undefined, undefined, `[processScenes] Failed to process scene "${program.name}" (${program.id}): ${errorMsg}`, "warn");
			}
		}

		for (const duid in programs) {
			await this.ensureState(`Devices.${duid}.programs.startProgram`, {
				name: "Start saved program",
				type: "string",
				write: true,
				states: programs[duid],
			});
		}
	}

	/**
	 * Clears all timeouts and intervals.
	 */
	clearTimersAndIntervals() {
		this.commandTimeouts.forEach((timeout) => this.clearTimeout(timeout));
		this.commandTimeouts.clear();

		this.deviceManager.stopPolling();
		this.requestsHandler.clearQueue();
	}

	/** Timestamp keys we format as readable date string; all other keys passed through as-is. */
	private static readonly DEVICE_INFO_DATE_KEYS = ["activeTime", "active_time", "createTime", "create_time"];
	private static readonly DEVICE_INFO_NAME_OVERRIDES: Record<string, string> = {
		activeTime: "Last Activity",
		active_time: "Last Activity",
		createTime: "Created At",
		create_time: "Created At"
	};

	/**
	 * Updates deviceInfo from cloud HomeData: all top-level device fields are written to
	 * Devices.${duid}.deviceInfo.* (names unchanged). Scalars as-is; objects/arrays as JSON string.
	 */
	async updateDeviceInfo(duid: string, devices: Device[]) {
		const device = devices.find((d) => d.duid === duid);
		if (!device) return;

		const raw = device as unknown as Record<string, unknown>;
		for (const attr of Object.keys(raw)) {
			let value: ioBroker.StateValue = raw[attr] as ioBroker.StateValue;
			if (typeof value === "object" && value !== null) {
				value = JSON.stringify(value);
			}
			const common: Partial<ioBroker.StateCommon> = {};
			let finalValue: ioBroker.StateValue = value;
			if (Roborock.DEVICE_INFO_NAME_OVERRIDES[attr]) {
				common.name = Roborock.DEVICE_INFO_NAME_OVERRIDES[attr];
			}
			if (Roborock.DEVICE_INFO_DATE_KEYS.includes(attr) && typeof value === "number") {
				finalValue = this.formatRoborockDate(value);
				common.type = "string";
			} else {
				common.type = typeof finalValue as ioBroker.CommonType;
			}
			await this.ensureState(`Devices.${duid}.deviceInfo.${attr}`, common);
			await this.setStateChanged(`Devices.${duid}.deviceInfo.${attr}`, { val: finalValue, ack: true });
		}
	}

	/**
	 * Checks for new firmware.
	 */
	async checkForNewFirmware(duid: string) {
		const isLocal = this.local_api.isLocalDevice(duid);
		if (!isLocal) return;

		try {
			this.rLog("HTTP", duid, "Debug", undefined, undefined, "[checkForNewFirmware] Checking for firmware update...", "debug");
			const update = await this.http_api.getFirmwareStates(duid);
			this.rLog("HTTP", duid, "Debug", undefined, undefined, `[checkForNewFirmware] Result: ${JSON.stringify(update)}`, "debug");

			if (update.data.result) {
				for (const state in update.data.result) {
					const value = update.data.result[state];
					await this.ensureState(`Devices.${duid}.updateStatus.${state}`, { type: typeof value as ioBroker.CommonType });
					await this.setStateChanged(`Devices.${duid}.updateStatus.${state}`, { val: value, ack: true });
				}
			} else {
				this.rLog("HTTP", duid, "Warn", undefined, undefined, "[checkForNewFirmware] No result in firmware update response", "warn");
			}
		} catch (error: unknown) {
			this.rLog("HTTP", duid, "Warn", undefined, undefined, `Failed to check for new firmware: ${this.errorMessage(error)}`, "warn");
		}
	}

	/**
	 * Creates a state if it doesn't exist, applying translations.
	 */
	public async ensureState(path: string, commonOptions: Partial<ioBroker.StateCommon>, native: Record<string, unknown> = {}) {
		const stateName = path.split(".").pop() || path;
		// Allow empty string as name if explicitly provided. Only use fallback if name is undefined.
		const translatedName = commonOptions.name !== undefined ? commonOptions.name : (this.translations[stateName] || stateName);

		const baseCommon: ioBroker.StateCommon = {
			name: translatedName,
			type: "string",
			role: "value",
			read: true,
			write: false,
		};

		const finalCommon = { ...baseCommon, ...commonOptions, name: translatedName };
		if (finalCommon.def === undefined || finalCommon.def === null || finalCommon.def === "") {
			delete finalCommon.def;
		}

		let oldObj: ioBroker.Object | null | undefined;
		try {
			oldObj = await this.getObjectAsync(path);
		} catch {
			oldObj = null; // Does not exist
		}

		// Check if object exists AND if its metadata is different from what we need
		if (oldObj && !this.hasCommonChanged(oldObj.common as ioBroker.StateCommon, finalCommon)) {
			return;
		}

		try {
			if (oldObj) {
				// Object exists, but metadata changed
				// Safely merge common properties
				const newCommon = { ...oldObj.common, ...finalCommon };

				// Force extension to apply changes
				await this.extendObject(path, { common: newCommon });
			} else {
				// Object does not exist, create it new.
				// Provide mandatory defaults for a valid ioBroker state object.
				const defaults: Partial<ioBroker.StateCommon> = {
					role: "state",
					read: true,
					write: false,
					type: "mixed"
				};
				const commonObj: ioBroker.StateCommon = { ...defaults, ...finalCommon } as ioBroker.StateCommon;

				if (!commonObj.type) commonObj.type = "mixed";

				await this.setObject(path, {
					type: "state",
					common: commonObj,
					native: native,
				});
			}
		} catch (e: unknown) {
			this.rLog("System", null, "Error", undefined, undefined, `[ensureState] Failed to update/create object for "${path}": ${this.errorMessage(e)}`, "error");
		}
	}

	/**
	 * Helper to check if common properties of an object have meaningfully changed.
	 *
	 * PERFORMANCE CRITICAL:
	 * This method prevents "Write Storms" to the ioBroker database (objects.json/redis).
	 * Writing objects is expensive (disk I/O) and triggers system-wide events.
	 * We only write if the definition (name, role, unit, etc.) has actually changed.
	 * This significantly reduces CPU usage and disk wear on startup.
	 */
	private hasCommonChanged(oldCommon: ioBroker.StateCommon, newCommon: Partial<ioBroker.StateCommon>): boolean {
		if (newCommon.type !== undefined && oldCommon.type !== newCommon.type) return true;
		if (newCommon.name !== undefined && this.stringifySorted(oldCommon.name) !== this.stringifySorted(newCommon.name)) return true;
		if (newCommon.states !== undefined && this.stringifySorted(oldCommon.states) !== this.stringifySorted(newCommon.states)) return true;
		if (newCommon.role !== undefined && oldCommon.role !== newCommon.role) return true;
		if (newCommon.unit !== undefined && oldCommon.unit !== newCommon.unit) return true;
		if (newCommon.min !== undefined && oldCommon.min !== newCommon.min) return true;
		if (newCommon.max !== undefined && oldCommon.max !== newCommon.max) return true;
		if (newCommon.icon !== undefined && oldCommon.icon !== newCommon.icon) return true;
		if (newCommon.read !== undefined && oldCommon.read !== newCommon.read) return true;
		if (newCommon.write !== undefined && oldCommon.write !== newCommon.write) return true;
		if (newCommon.def !== undefined && oldCommon.def !== newCommon.def) return true;
		return false;
	}

	/**
	 * JSON.stringify with sorted keys for consistent object comparison.
	 */
	private stringifySorted(obj: unknown): string {
		return JSON.stringify(obj, (_key, value) => {
			if (value && typeof value === "object" && !Array.isArray(value)) {
				return Object.keys(value)
					.sort()
					.reduce((sorted: Record<string, unknown>, key) => {
						sorted[key] = (value as Record<string, unknown>)[key];
						return sorted;
					}, {});
			}
			return value;
		});
	}

	/**
	 * Safe string from any thrown value (message if Error, else String(e)).
	 * Use in catch (e: unknown) instead of repeating e instanceof Error ? e.message : String(e).
	 */
	public errorMessage(e: unknown): string {
		return e instanceof Error ? e.message : String(e);
	}

	/**
	 * Stack trace if Error, else message, else String(e).
	 */
	public errorStack(e: unknown): string {
		if (e instanceof Error) return e.stack ?? e.message;
		return String(e);
	}

	/**
	 * Helper to format Roborock timestamps (seconds) to locale string.
	 */
	public formatRoborockDate(timestamp: number): string {
		return new Date(timestamp * 1000).toLocaleString();
	}

	/**
	 * Helper to safely parse JSON strings that look like objects/arrays.
	 */
	private tryParseJson(value: string): unknown | undefined {
		const trimmed = value.trim();
		if ((trimmed.startsWith("{") || trimmed.startsWith("[")) && (trimmed.endsWith("}") || trimmed.endsWith("]"))) {
			try {
				return JSON.parse(trimmed);
			} catch {
				return undefined;
			}
		}
		return undefined;
	}

	/**
	 * Creates a folder if it doesn't exist, applying translations.
	 */
	async ensureFolder(path: string, customName?: string | ioBroker.StringOrTranslated) {
		const attribute = path.split(".").pop() || path;
		const name = customName || this.translations[attribute] || attribute;

		let oldObj: ioBroker.Object | null | undefined;
		try {
			oldObj = await this.getObjectAsync(path);
		} catch {
			oldObj = null; // Does not exist
		}

		if (!oldObj || oldObj.type !== "folder") {
			await this.setObject(path, {
				type: "folder",
				common: {
					name: name
				},
				native: {}
			});
		} else if (customName !== undefined) {
			// Only update name when explicitly passed; avoid overwriting with path segment when ensuring existence (issue #1140)
			const currentName = oldObj.common.name;
			const isDifferent = JSON.stringify(currentName) !== JSON.stringify(name);

			if (isDifferent) {
				try {
					await this.extendObject(path, { common: { name } });
				} catch (e: unknown) {
					this.rLog("System", null, "Error", undefined, undefined, `Failed to update folder name for ${path}: ${this.errorMessage(e)}`, "error");
				}
			}
		}
	}

	/**
	 * Gets the protocol version for a device.
	 */
	async getDeviceProtocolVersion(duid: string): Promise<string> {
		const tcpConnected = this.local_api.isConnected(duid);

		if (tcpConnected) {
			const localPv = this.local_api.getLocalProtocolVersion(duid);
			if (localPv) return localPv;
		}

		const devices = this.http_api.getDevices();
		const device = devices ? devices.find((d) => d.duid == duid) : undefined;
		return device?.pv || "1.0";
	}

	/**
	 * Returns the B01 sub-variant for a device when applicable.
	 * Q10 behaves event-driven and is routed separately from classic B01/Q7.
	 */
	async getB01Variant(duid: string): Promise<B01Variant | null> {
		const handler = this.deviceFeatureHandlers.get(duid);
		if (handler && "b01Variant" in handler && typeof (handler as { b01Variant?: unknown }).b01Variant === "string") {
			return (handler as { b01Variant: B01Variant }).b01Variant;
		}

		const pv = await this.getDeviceProtocolVersion(duid);
		if (pv !== "B01") return null;

		const model = this.http_api.getRobotModel(duid);
		return model ? getB01VariantFromModel(model) : "Q7";
	}

	/**
	 * Starts the go2rtc process if cameras are present.
	 */
	async start_go2rtc() {
		const devices = this.http_api.getDevices() || [];
		const localKeys = this.http_api.getMatchedLocalKeys();
		const { u, s, k } = this.http_api.get_rriot();

		const apiPort = 1984 + this.instance; // API/Web Port
		const rtspPort = 8554 + this.instance; // RTSP Port
		const go2rtcConfig = {
			server: { listen: `:${apiPort}` },
			rtsp: { listen: `:${rtspPort}` },
			streams: {} as Record<string, string>,
		};
		let cameraCount = 0;

		for (const device of devices) {
			const duid = device.duid;
			const handler = this.deviceFeatureHandlers.get(duid);
			const localKey = localKeys.get(duid);

			if (handler && localKey && handler.hasStaticFeature(Feature.Camera)) {
				cameraCount++;
				go2rtcConfig.streams[duid] = `roborock://mqtt-eu-3.roborock.com:8883?u=${u}&s=${s}&k=${k}&did=${duid}&key=${localKey}&pin=${this.config.cameraPin}`;
			}
		}

		if (cameraCount > 0 && go2rtcPath) {
			try {
				this.go2rtcProcess = spawn(go2rtcPath.toString(), ["-config", JSON.stringify(go2rtcConfig)], { shell: false, detached: false, windowsHide: true });

				this.go2rtcProcess!.on("error", (err) => this.rLog("Local", null, "Error", undefined, undefined, `go2rtc start error: ${err.message}`, "error"));
				this.go2rtcProcess!.stdout!.on("data", (data) => this.rLog("Local", null, "Debug", undefined, undefined, `go2rtc output: ${data.toString().trim()}`, "debug"));
				this.go2rtcProcess!.stderr!.on("data", (data) => {
					const msg = data.toString().trim();
					const isShutdown = /signal:\s*terminated|exit with signal/i.test(msg);
					this.rLog("Local", null, isShutdown ? "Info" : "Error", undefined, undefined, `go2rtc ${isShutdown ? "output" : "error output"}: ${msg}`, isShutdown ? "info" : "error");
				});

				// Remove the process reference on exit to prevent double-kill attempts
				this.go2rtcProcess!.on("exit", () => {
					this.go2rtcProcess = null;
				});

				// Safety net: Ensure child process ensures if Node.js crashes/exits
				this.onExitBound = () => {
					if (this.go2rtcProcess) {
						this.go2rtcProcess.kill();
					}
				};
				process.on("exit", this.onExitBound);
			} catch (error: unknown) {
				this.rLog("Local", null, "Error", undefined, undefined, `Failed to spawn go2rtc: ${this.errorMessage(error)}`, "error");
			}
		}
	}

	/**
	 * Processes A01 (Tuya) protocol messages.
	 */
	async processA01(duid: string, response: { dps?: Record<string, unknown> }): Promise<void> {
		if (!response?.dps) {
			this.rLog("Local", duid, "Warn", "A01", undefined, `Invalid response: ${JSON.stringify(response)}`, "warn");
			return;
		}

		const determineType = (value: unknown): ioBroker.CommonType => {
			const t = typeof value;
			if (t === "number") return "number";
			if (t === "boolean") return "boolean";
			if (t === "object" && value !== null) return "object";
			return "string";
		};

		// Recursive helper for nested JSON objects
		const processNested = async (basePath: string, obj: Record<string, unknown>) => {
			for (const [key, value] of Object.entries(obj)) {
				const path = `${basePath}.${key}`;
				if (typeof value === "object" && value !== null && !Array.isArray(value)) {
					await this.ensureFolder(path);
					await processNested(path, value as Record<string, unknown>);
				} else {
					const val = typeof value === "object" || value === null ? JSON.stringify(value) : (value as ioBroker.StateValue);
					await this.ensureState(path, { name: key, type: determineType(value), write: false });
					await this.setStateChanged(path, { val, ack: true });
				}
			}
		};

		for (const [id, value] of Object.entries(response.dps)) {
			// A01 states are not defined in main.ts anymore, this is just a fallback name
			const stateName = id;
			let parsedValue = value;
			let isJson = false;

			if (typeof value === "object" && value !== null) {
				parsedValue = value;
				isJson = true;
			} else if (typeof value === "string") {
				const maybeJson = this.tryParseJson(value);
				if (maybeJson !== undefined) {
					parsedValue = maybeJson;
					isJson = true;
				}
			}

			if (isJson && typeof parsedValue === "object" && parsedValue !== null) {
				const basePath = `Devices.${duid}.${id}`; // Use ID as folder name
				await this.ensureFolder(basePath);
				await processNested(basePath, parsedValue as Record<string, unknown>);
			} else {
				const path = `Devices.${duid}.deviceStatus.${id}`;
				await this.ensureState(path, { name: stateName, type: determineType(value), write: false });
				await this.setStateChanged(path, { val: parsedValue as ioBroker.StateValue, ack: true });
			}
		}
	}

	/**
	 * Resets the MQTT API instance.
	 */
	async resetMqttApi() {
		this.rLog("System", null, "Info", undefined, undefined, "Resetting MQTT API instance...", "info");
		if (this.mqtt_api) {
			this.mqtt_api.cleanup();
			this.requestsHandler.clearQueue(); // Prevents pending promises
		}
		// Create a new MQTT API instance and initialize it
		this.mqtt_api = new mqtt_api(this);
		await this.mqtt_api.init();
		this.rLog("System", null, "Info", undefined, undefined, "MQTT API instance has been reset.", "info");
	}

	/**
	 * Centralized error handler.
	 */
	async catchError(error: unknown, attribute?: string, duid?: string) {
		const robotModel = duid ? this.http_api.getRobotModel(duid) : "unknown";
		const stack = this.errorStack(error);
		const errorMsg = this.errorMessage(error);
		const msg = `Failed processing ${attribute || "task"} on ${duid || "adapter"} (${robotModel}): ${stack}`;

		if (errorMsg.includes("retry") || errorMsg.includes("locating") || errorMsg.includes("timed out")) {
			this.rLog("System", duid, "Warn", undefined, undefined, msg, "warn");
		} else {
			this.rLog("System", duid, "Error", undefined, undefined, msg, "error");
			if (this.sentryInstance) {
				this.sentryInstance.getSentryObject().captureException(error);
			}
		}
	}

	/**
	 * Centralized Logging Function for Protocol Messages
	 * Format: [Connection] [duid] direction [version] [protocol] [ID: id] | payload
	 */
	rLog(connection: "MQTT" | "TCP" | "UDP" | "HTTP" | "Cloud" | "Local" | "System" | "MapManager" | "Requests" | "Unknown", duid: string | null | undefined, direction: "<-" | "->" | "Info" | "Error" | "Warn" | "Debug", version: string | undefined, protocol: string | number | undefined, message: string, level: "debug" | "info" | "warn" | "error" = "debug", msgId?: string | number): void {
		// Use == as a neutral placeholder for alignment if it's not actual traffic (<- or ->).
		const directionDisplay = (direction === "<-" || direction === "->") ? direction : "==";

		// Construct prefix and message body using parts to ensure clean spacing.
		const parts = [directionDisplay, `[${connection}]`];
		if (duid) parts.push(`[${duid}]`);
		if (version) parts.push(`[${version}]`);
		if (protocol) parts.push(`[${protocol}]`);
		if (msgId !== undefined) parts.push(`[ID: ${msgId}]`);

		const logMsg = `${parts.join(" ")} | ${message}`;

		switch (level) {
			case "debug":
				this.log.debug(logMsg);
				break;
			case "info":
				this.log.info(logMsg);
				break;
			case "warn":
				this.log.warn(logMsg);
				break;
			case "error":
				this.log.error(logMsg);
				break;
		}
	}

	// Helper to handle floor switching logic (extracted to reduce nesting)
	async handleFloorSwitch(duid: string, mapFlag: number, stateId: string): Promise<void> {
		const handler = this.deviceFeatureHandlers.get(duid);
		if (!handler) return;

		try {
			this.rLog("Requests", duid, "Info", handler.protocolVersion || undefined, undefined, `[floorSwitch] Loading map ${mapFlag}`, "info");
			// 1. Send load command and wait for robot ACK
			await this.requestsHandler.sendRequest(duid, "load_multi_map", [mapFlag], { timeout: 60000 });

			this.rLog("Requests", duid, "Info", handler.protocolVersion || undefined, undefined, "[floorSwitch] Load acknowledged, verifying map index sync", "info");

			// Failsafe: Robot says "ok" but might need a few seconds to switch currentMapIndex
			const startTime = Date.now();
			let verified = false;
			for (let i = 0; i < 10; i++) {
				await handler.updateStatus();
				const currentIndex = handler.getCurrentMapIndex();
				// Use exposed method if available or cast to any to access internal if needed (assuming logic added to V1Feature)
				// For now relying on public interface which delegates to V1MapService
				const rawStatus = (handler as any).mapService ? (handler as any).mapService.lastMapStatus : -1;

				const elapsed = Date.now() - startTime;

				// Verify using both index match and verifying raw status supports it
				if (currentIndex === mapFlag) {
					this.rLog("Requests", duid, "Info", handler.protocolVersion || undefined, undefined, `[floorSwitch] Synced map index to ${currentIndex} (status=${rawStatus}, attempt=${i + 1}/10, elapsed=${elapsed}ms)`, "info");
					verified = true;
					break;
				}
				this.rLog("Requests", duid, "Info", handler.protocolVersion || undefined, undefined, `[floorSwitch] Waiting for sync (current=${currentIndex}, target=${mapFlag}, status=${rawStatus}, attempt=${i + 1}/10, elapsed=${elapsed}ms)`, "info");
				await new Promise(resolve => setTimeout(resolve, 2000));
			}

			if (!verified) {
				this.rLog("Requests", duid, "Warn", handler.protocolVersion || undefined, undefined, `[floorSwitch] Map index did not sync to ${mapFlag} after retries; proceeding`, "warn");
			}

			await handler.updateMultiMapsList();
			await handler.updateRoomMapping();
			await handler.updateMap();

			this.rLog("Requests", duid, "Info", handler.protocolVersion || undefined, undefined, `[floorSwitch] Completed switch to map ${mapFlag}`, "info");
		} catch (e: unknown) {
			this.catchError(e, "floorSwitch", duid);
		} finally {
			// Reset button
			this.setResetTimeout(stateId);
		}
	}
}

if (require.main !== module) {
	// Export the constructor in compact mode
	module.exports = (options: Partial<utils.AdapterOptions>) => new Roborock(options);
} else {
	// otherwise start the instance directly
	new Roborock();
}
