import { configDir } from "#config_dir";
import { ZWaveError, ZWaveErrorCodes, deflateSync, digest, inflateSync, } from "@zwave-js/core";
import { Bytes, cloneDeep, enumFilesRecursive, formatId, getenv, num2hex, padVersion, pathExists, pick, readTextFile, stringify, writeTextFile, } from "@zwave-js/shared";
import { isArray, isObject } from "alcalzone-shared/typeguards";
import JSON5 from "json5";
import path from "pathe";
import semverGt from "semver/functions/gt.js";
import { clearTemplateCache, readJsonWithTemplate } from "../JsonTemplate.js";
import { hexKeyRegex4Digits, throwInvalidConfig } from "../utils_safe.js";
import { ConditionalAssociationConfig, } from "./AssociationConfig.js";
import { ConditionalCompatConfig } from "./CompatConfig.js";
import { evaluateDeep, validateCondition } from "./ConditionalItem.js";
import { parseConditionalPrimitive, } from "./ConditionalPrimitive.js";
import { ConditionalDeviceMetadata, } from "./DeviceMetadata.js";
import { ConditionalEndpointConfig, } from "./EndpointConfig.js";
import { parseConditionalParamInformationMap, } from "./ParamInformation.js";
import { ConditionalSceneConfig } from "./SceneConfig.js";
export const embeddedDevicesDir = path.join(configDir, "devices");
const fulltextIndexPath = path.join(embeddedDevicesDir, "fulltext_index.json");
export function getDevicesPaths(configDir) {
    const devicesDir = path.join(configDir, "devices");
    const indexPath = path.join(devicesDir, "index.json");
    return { devicesDir, indexPath };
}
async function hasChangedDeviceFiles(fs, devicesRoot, dir, lastChange) {
    // Check if there are any files BUT index.json that were changed
    // or directories that were modified
    const filesAndDirs = await fs.readDir(dir);
    for (const f of filesAndDirs) {
        const fullPath = path.join(dir, f);
        const stat = await fs.stat(fullPath);
        if ((dir !== devicesRoot || f !== "index.json")
            && (stat.isFile() || stat.isDirectory())
            && stat.mtime > lastChange) {
            return true;
        }
        else if (stat.isDirectory()) {
            // we need to go deeper!
            if (await hasChangedDeviceFiles(fs, devicesRoot, fullPath, lastChange)) {
                return true;
            }
        }
    }
    return false;
}
/**
 * Read all device config files from a given directory and return them as index entries.
 * Does not update the index itself.
 */
async function generateIndex(fs, devicesDir, isEmbedded, extractIndexEntries, logger) {
    const index = [];
    clearTemplateCache();
    const configFiles = await enumFilesRecursive(fs, devicesDir, (file) => file.endsWith(".json")
        && !file.endsWith("index.json")
        && !file.includes("/templates/")
        && !file.includes("\\templates\\"));
    // Add the embedded devices dir as a fallback if necessary
    const fallbackDirs = devicesDir !== embeddedDevicesDir
        ? [embeddedDevicesDir]
        : undefined;
    for (const file of configFiles) {
        const relativePath = path
            .relative(devicesDir, file)
            .replaceAll("\\", "/");
        // Try parsing the file
        try {
            const config = await DeviceConfig.from(fs, file, isEmbedded, {
                rootDir: devicesDir,
                fallbackDirs,
                relative: true,
            });
            // Add the file to the index
            index.push(...extractIndexEntries(config).map((entry) => {
                const ret = {
                    ...entry,
                    filename: relativePath,
                };
                // Only add the root dir to the index if necessary
                if (devicesDir !== embeddedDevicesDir) {
                    ret.rootDir = devicesDir;
                }
                return ret;
            }));
        }
        catch (e) {
            const message = `Error parsing config file ${relativePath}: ${e.message}`;
            // Crash hard during tests, just print an error when in production systems.
            // A user could have changed a config file
            if (process.env.NODE_ENV === "test" || !!getenv("CI")) {
                throw new ZWaveError(message, ZWaveErrorCodes.Config_Invalid);
            }
            else {
                logger?.print(message, "error");
            }
        }
    }
    return index;
}
async function loadDeviceIndexShared(fs, devicesDir, indexPath, extractIndexEntries, logger) {
    // The index file needs to be regenerated if it does not exist
    let needsUpdate = !(await pathExists(fs, indexPath));
    let index;
    let mtimeIndex;
    // ...or if cannot be parsed
    if (!needsUpdate) {
        try {
            const fileContents = await readTextFile(fs, indexPath, "utf8");
            index = JSON5.parse(fileContents);
            mtimeIndex = (await fs.stat(indexPath)).mtime;
        }
        catch {
            logger?.print("Error while parsing index file - regenerating...", "warn");
            needsUpdate = true;
        }
        finally {
            if (!index) {
                logger?.print("Index file was malformed - regenerating...", "warn");
                needsUpdate = true;
            }
        }
    }
    // ...or if there were any changes in the file system
    if (!needsUpdate) {
        needsUpdate = await hasChangedDeviceFiles(fs, devicesDir, devicesDir, mtimeIndex);
        if (needsUpdate) {
            logger?.print("Device configuration files on disk changed - regenerating index...", "verbose");
        }
    }
    if (needsUpdate) {
        // Read all files from disk and generate an index
        index = await generateIndex(fs, devicesDir, true, extractIndexEntries, logger);
        // Save the index to disk
        try {
            await writeTextFile(fs, path.join(indexPath), `// This file is auto-generated. DO NOT edit it by hand if you don't know what you're doing!"
${stringify(index, "\t")}
`, "utf8");
            logger?.print("Device index regenerated", "verbose");
        }
        catch (e) {
            logger?.print(`Writing the device index to disk failed: ${e.message}`, "error");
        }
    }
    return index;
}
/**
 * @internal
 * Loads the index file to quickly access the device configs.
 * Transparently handles updating the index if necessary
 */
export async function generatePriorityDeviceIndex(fs, deviceConfigPriorityDir, logger) {
    return (await generateIndex(fs, deviceConfigPriorityDir, false, (config) => config.devices.map((dev) => ({
        manufacturerId: formatId(config.manufacturerId.toString(16)),
        manufacturer: config.manufacturer,
        label: config.label,
        productType: formatId(dev.productType),
        productId: formatId(dev.productId),
        firmwareVersion: config.firmwareVersion,
        ...(config.preferred ? { preferred: true } : {}),
        rootDir: deviceConfigPriorityDir,
    })), logger)).map(({ filename, ...entry }) => ({
        ...entry,
        // The generated index makes the filenames relative to the given directory
        // but we need them to be absolute
        filename: path.join(deviceConfigPriorityDir, filename),
    }));
}
/**
 * @internal
 * Loads the index file to quickly access the device configs.
 * Transparently handles updating the index if necessary
 */
export async function loadDeviceIndexInternal(fs, logger, externalConfigDir) {
    const { devicesDir, indexPath } = getDevicesPaths(externalConfigDir || configDir);
    return loadDeviceIndexShared(fs, devicesDir, indexPath, (config) => config.devices.map((dev) => ({
        manufacturerId: formatId(config.manufacturerId.toString(16)),
        manufacturer: config.manufacturer,
        label: config.label,
        productType: formatId(dev.productType),
        productId: formatId(dev.productId),
        firmwareVersion: config.firmwareVersion,
        ...(config.preferred ? { preferred: true } : {}),
    })), logger);
}
/**
 * @internal
 * Loads the full text index file to quickly search the device configs.
 * Transparently handles updating the index if necessary
 */
export async function loadFulltextDeviceIndexInternal(fs, logger) {
    // This method is not meant to operate with the external device index!
    return loadDeviceIndexShared(fs, embeddedDevicesDir, fulltextIndexPath, (config) => config.devices.map((dev) => ({
        manufacturerId: formatId(config.manufacturerId.toString(16)),
        manufacturer: config.manufacturer,
        label: config.label,
        description: config.description,
        productType: formatId(dev.productType),
        productId: formatId(dev.productId),
        firmwareVersion: config.firmwareVersion,
        ...(config.preferred ? { preferred: true } : {}),
        rootDir: embeddedDevicesDir,
    })), logger);
}
function isHexKeyWith4Digits(val) {
    return typeof val === "string" && hexKeyRegex4Digits.test(val);
}
const firmwareVersionRegex = /^\d{1,3}\.\d{1,3}(\.\d{1,3})?$/;
function isFirmwareVersion(val) {
    return (typeof val === "string"
        && firmwareVersionRegex.test(val)
        && val
            .split(".")
            .map((str) => parseInt(str, 10))
            .every((num) => num >= 0 && num <= 255));
}
const deflateDict = Bytes.from(
// Substrings appearing in the device config files in descending order of frequency
// except for very short ones like 0, 1, ...
// WARNING: THIS MUST NOT BE CHANGED! Doing so breaks decompressing stored hashes.
[
    `"parameterNumber":`,
    `255`,
    `"value":`,
    `"defaultValue":`,
    `"valueSize":`,
    `"maxValue":`,
    `"minValue":`,
    `"options":`,
    `true`,
    `false`,
    `"allowManualEntry":`,
    `"maxNodes":`,
    `100`,
    `"unsigned":`,
    `"paramInformation":`,
    `"isLifeline":`,
    `"seconds"`,
    `99`,
    `127`,
    `"%"`,
    `65535`,
    `32767`,
    `"minutes"`,
    `"endpoints":`,
    `"hours"`,
    `"multiChannel":`,
]
    .join(""), "utf8");
/** This class represents a device config entry whose conditional settings have not been evaluated yet */
export class ConditionalDeviceConfig {
    static async from(fs, filename, isEmbedded, options) {
        const { relative, rootDir } = options;
        const relativePath = relative
            ? path.relative(rootDir, filename).replaceAll("\\", "/")
            : filename;
        const json = await readJsonWithTemplate(fs, filename, [
            options.rootDir,
            ...(options.fallbackDirs ?? []),
        ]);
        return new ConditionalDeviceConfig(relativePath, isEmbedded, json);
    }
    constructor(filename, isEmbedded, definition) {
        this.filename = filename;
        this.isEmbedded = isEmbedded;
        if (!isHexKeyWith4Digits(definition.manufacturerId)) {
            throwInvalidConfig(`device`, `packages/config/config/devices/${filename}:
manufacturer id must be a lowercase hexadecimal number with 4 digits`);
        }
        this.manufacturerId = parseInt(definition.manufacturerId, 16);
        for (const prop of ["manufacturer", "label", "description"]) {
            this[prop] = parseConditionalPrimitive(filename, "string", prop, definition[prop]);
        }
        if (!isArray(definition.devices)
            || !definition.devices.every((dev) => isObject(dev)
                && isHexKeyWith4Digits(dev.productType)
                && isHexKeyWith4Digits(dev.productId))) {
            throwInvalidConfig(`device`, `packages/config/config/devices/${filename}:
devices is malformed (not an object or type/id that is not a lowercase 4-digit hex key)`);
        }
        this.devices = definition.devices.map(({ productType, productId }) => ({
            productType: parseInt(productType, 16),
            productId: parseInt(productId, 16),
        }));
        if (!isObject(definition.firmwareVersion)
            || !isFirmwareVersion(definition.firmwareVersion.min)
            || !isFirmwareVersion(definition.firmwareVersion.max)) {
            throwInvalidConfig(`device`, `packages/config/config/devices/${filename}:
firmwareVersion is malformed or invalid. Must be x.y or x.y.z where x, y, and z are integers between 0 and 255`);
        }
        else {
            const { min, max } = definition.firmwareVersion;
            if (semverGt(padVersion(min), padVersion(max))) {
                throwInvalidConfig(`device`, `packages/config/config/devices/${filename}:
firmwareVersion.min ${min} must not be greater than firmwareVersion.max ${max}`);
            }
            this.firmwareVersion = { min, max };
        }
        if (definition.preferred != undefined
            && definition.preferred !== true) {
            throwInvalidConfig(`device`, `packages/config/config/devices/${filename}:
preferred must be true or omitted`);
        }
        this.preferred = !!definition.preferred;
        if (definition.endpoints != undefined) {
            const endpoints = new Map();
            if (!isObject(definition.endpoints)) {
                throwInvalidConfig(`device`, `packages/config/config/devices/${filename}:
endpoints is not an object`);
            }
            for (const [key, ep] of Object.entries(definition.endpoints)) {
                if (!/^\d+$/.test(key)) {
                    throwInvalidConfig(`device`, `packages/config/config/devices/${filename}:
found non-numeric endpoint index "${key}" in endpoints`);
                }
                const epIndex = parseInt(key, 10);
                endpoints.set(epIndex, new ConditionalEndpointConfig(this, epIndex, ep));
            }
            this.endpoints = endpoints;
        }
        if (definition.associations != undefined) {
            const associations = new Map();
            if (!isObject(definition.associations)) {
                throwInvalidConfig(`device`, `packages/config/config/devices/${filename}:
associations is not an object`);
            }
            for (const [key, assocDefinition] of Object.entries(definition.associations)) {
                if (!/^[1-9][0-9]*$/.test(key)) {
                    throwInvalidConfig(`device`, `packages/config/config/devices/${filename}:
found non-numeric group id "${key}" in associations`);
                }
                const keyNum = parseInt(key, 10);
                associations.set(keyNum, new ConditionalAssociationConfig(filename, keyNum, assocDefinition));
            }
            this.associations = associations;
        }
        if (definition.paramInformation != undefined) {
            this.paramInformation = parseConditionalParamInformationMap(definition, this);
        }
        if (definition.proprietary != undefined) {
            if (!isObject(definition.proprietary)) {
                throwInvalidConfig(`device`, `packages/config/config/devices/${filename}:
proprietary is not an object`);
            }
            this.proprietary = definition.proprietary;
        }
        if (definition.compat != undefined) {
            if (isArray(definition.compat)
                && definition.compat.every((item) => isObject(item))) {
                // Make sure all conditions are valid
                for (const entry of definition.compat) {
                    validateCondition(filename, entry, `At least one entry of compat contains an`);
                }
                this.compat = definition.compat.map((item) => new ConditionalCompatConfig(filename, item));
            }
            else if (isObject(definition.compat)) {
                this.compat = new ConditionalCompatConfig(filename, definition.compat);
            }
            else {
                throwInvalidConfig(`device`, `packages/config/config/devices/${filename}:
compat must be an object or any array of conditional objects`);
            }
        }
        if (definition.metadata != undefined) {
            if (!isObject(definition.metadata)) {
                throwInvalidConfig(`device`, `packages/config/config/devices/${filename}:
metadata is not an object`);
            }
            this.metadata = new ConditionalDeviceMetadata(filename, definition.metadata);
        }
        if (definition.scenes != undefined) {
            const scenes = new Map();
            if (!isObject(definition.scenes)) {
                throwInvalidConfig(`device`, `packages/config/config/devices/${filename}:
scenes is not an object`);
            }
            for (const [key, sceneDefinition] of Object.entries(definition.scenes)) {
                if (!/^[1-9][0-9]*$/.test(key)) {
                    throwInvalidConfig(`device`, `packages/config/config/devices/${filename}:
invalid scene id "${key}" in scenes - must be a positive integer (1-255)`);
                }
                const keyNum = parseInt(key, 10);
                if (keyNum < 1 || keyNum > 255) {
                    throwInvalidConfig(`device`, `packages/config/config/devices/${filename}:
scene number ${keyNum} must be between 1 and 255`);
                }
                scenes.set(keyNum, new ConditionalSceneConfig(filename, keyNum, sceneDefinition));
            }
            this.scenes = scenes;
        }
    }
    filename;
    manufacturer;
    manufacturerId;
    label;
    description;
    devices;
    firmwareVersion;
    /** Mark this configuration as preferred over other config files with an overlapping firmware range */
    preferred;
    endpoints;
    associations;
    scenes;
    paramInformation;
    /**
     * Contains manufacturer-specific support information for the
     * ManufacturerProprietary CC
     */
    proprietary;
    /** Contains compatibility options */
    compat;
    /** Contains instructions and other metadata for the device */
    metadata;
    /** Whether this is an embedded configuration or not */
    isEmbedded;
    evaluate(deviceId) {
        return new DeviceConfig(this.filename, this.isEmbedded, evaluateDeep(this.manufacturer, deviceId), this.manufacturerId, evaluateDeep(this.label, deviceId), evaluateDeep(this.description, deviceId), this.devices, this.firmwareVersion, this.preferred, evaluateDeep(this.endpoints, deviceId), evaluateDeep(this.associations, deviceId), evaluateDeep(this.scenes, deviceId), evaluateDeep(this.paramInformation, deviceId), this.proprietary, evaluateDeep(this.compat, deviceId), evaluateDeep(this.metadata, deviceId));
    }
}
export class DeviceConfig {
    static async from(fs, filename, isEmbedded, options) {
        const ret = await ConditionalDeviceConfig.from(fs, filename, isEmbedded, options);
        return ret.evaluate(options.deviceId);
    }
    constructor(filename, isEmbedded, manufacturer, manufacturerId, label, description, devices, firmwareVersion, preferred, endpoints, associations, scenes, paramInformation, proprietary, compat, metadata) {
        this.filename = filename;
        this.isEmbedded = isEmbedded;
        this.manufacturer = manufacturer;
        this.manufacturerId = manufacturerId;
        this.label = label;
        this.description = description;
        this.devices = devices;
        this.firmwareVersion = firmwareVersion;
        this.preferred = preferred;
        this.endpoints = endpoints;
        this.associations = associations;
        this.scenes = scenes;
        this.paramInformation = paramInformation;
        this.proprietary = proprietary;
        this.compat = compat;
        this.metadata = metadata;
    }
    filename;
    /** Whether this is an embedded configuration or not */
    isEmbedded;
    manufacturer;
    manufacturerId;
    label;
    description;
    devices;
    firmwareVersion;
    /** Mark this configuration as preferred over other config files with an overlapping firmware range */
    preferred;
    endpoints;
    associations;
    scenes;
    paramInformation;
    /**
     * Contains manufacturer-specific support information for the
     * ManufacturerProprietary CC
     */
    proprietary;
    /** Contains compatibility options */
    compat;
    /** Contains instructions and other metadata for the device */
    metadata;
    /** Returns the association config for a given endpoint */
    getAssociationConfigForEndpoint(endpointIndex, group) {
        if (endpointIndex === 0) {
            // The root endpoint's associations may be configured separately or as part of "endpoints"
            return (this.associations?.get(group)
                ?? this.endpoints?.get(0)?.associations?.get(group));
        }
        else {
            // The other endpoints can only have a configuration as part of "endpoints"
            return this.endpoints?.get(endpointIndex)?.associations?.get(group);
        }
    }
    getHashable(version) {
        // We only need to compare the information that is persisted elsewhere:
        // - config parameters
        // - functional association settings
        // - CC-related compat flags
        let hashable = {
        // endpoints: {
        // 	associations: {},
        // 	paramInformation: []
        // },
        // proprietary: {},
        // compat: {},
        };
        const sortObject = (obj) => {
            const ret = {};
            for (const key of Object.keys(obj).toSorted()) {
                ret[key] = obj[key];
            }
            return ret;
        };
        const cloneAssociationConfig = (a) => {
            return sortObject(pick(a, ["maxNodes", "multiChannel", "isLifeline"]));
        };
        const cloneAssociationMap = (target, map) => {
            if (!map || !map.size)
                return;
            target.associations = {};
            for (const [key, value] of map) {
                target.associations[key] = cloneAssociationConfig(value);
            }
            target.associations = sortObject(target.associations);
        };
        const cloneParamInformationMap = (target, map) => {
            if (!map || !map.size)
                return;
            const getParamKey = (param) => `${param.parameterNumber}${param.valueBitMask ? `[${num2hex(param.valueBitMask)}]` : ""}`;
            target.paramInformation = [...map.values()]
                .toSorted((a, b) => getParamKey(a).localeCompare(getParamKey(b)))
                .map((p) => cloneDeep(p));
        };
        // Clone associations and param information on the root (ep 0) and endpoints
        {
            let ep0 = {};
            cloneAssociationMap(ep0, this.associations);
            cloneParamInformationMap(ep0, this.paramInformation);
            ep0 = sortObject(ep0);
            if (Object.keys(ep0).length > 0) {
                hashable.endpoints ??= {};
                hashable.endpoints[0] = ep0;
            }
        }
        if (this.endpoints) {
            for (const [index, endpoint] of this.endpoints) {
                let ep = {};
                cloneAssociationMap(ep, endpoint.associations);
                cloneParamInformationMap(ep, endpoint.paramInformation);
                ep = sortObject(ep);
                if (Object.keys(ep).length > 0) {
                    hashable.endpoints ??= {};
                    hashable.endpoints[index] = ep;
                }
            }
        }
        // Clone proprietary config
        if (this.proprietary && Object.keys(this.proprietary).length > 0) {
            hashable.proprietary = sortObject({ ...this.proprietary });
        }
        // Clone relevant compat flags
        if (this.compat) {
            let c = {};
            // Copy some simple flags over
            for (const prop of [
                "forceSceneControllerGroupCount",
                "mapRootReportsToEndpoint",
                "mapBasicSet",
                "preserveRootApplicationCCValueIDs",
                "preserveEndpoints",
                "removeEndpoints",
                "treatMultilevelSwitchSetAsEvent",
            ]) {
                if (this.compat[prop] != undefined) {
                    c[prop] = this.compat[prop];
                }
            }
            // Copy other, more complex flags
            if (this.compat.overrideQueries) {
                c.overrideQueries = Object.fromEntries(this.compat.overrideQueries["overrides"]);
            }
            if (this.compat.addCCs) {
                c.addCCs = Object.fromEntries([...this.compat.addCCs].map(([ccId, def]) => [
                    ccId,
                    Object.fromEntries(def.endpoints),
                ]));
            }
            if (this.compat.removeCCs) {
                c.removeCCs = Object.fromEntries(this.compat.removeCCs);
            }
            if (this.compat.treatSetAsReport) {
                c.treatSetAsReport = [...this.compat.treatSetAsReport]
                    .toSorted();
            }
            c = sortObject(c);
            if (Object.keys(c).length > 0) {
                hashable.compat = c;
            }
        }
        if (version >= 2) {
            // From version 2 and on, we ignore labels, descriptions and $purpose, and load them dynamically
            for (const ep of Object.values(hashable.endpoints ?? {})) {
                for (const param of ep.paramInformation ?? []) {
                    delete param.label;
                    delete param.description;
                    delete param.$purpose; // added later, but the point still stands
                    for (const opt of param.options ?? []) {
                        delete opt.label;
                    }
                }
            }
        }
        if (version < 3) {
            // Version 3 added the `allowed` field. When targeting older versions
            // and the allowed field only has a single range, replace it with
            // minValue/maxValue for compatibility
            for (const ep of Object.values(hashable.endpoints ?? {})) {
                for (const param of ep.paramInformation ?? []) {
                    if (isArray(param.allowed)
                        && param.allowed.length === 1
                        && isObject(param.allowed[0])) {
                        const allowed = param.allowed[0];
                        if (typeof allowed.from === "number"
                            && typeof allowed.to === "number"
                            && (allowed.step == undefined || allowed.step === 1)) {
                            param.minValue = allowed.from;
                            param.maxValue = allowed.to;
                            delete param.allowed;
                        }
                    }
                }
            }
        }
        if (version >= 4) {
            // From version 4 and on, all param information is applied dynamically
            // so it does not need to be part of the hash anymore.
            if (hashable.endpoints) {
                for (const [key, ep] of Object.entries(hashable.endpoints)) {
                    delete ep.paramInformation;
                    if (Object.keys(ep).length === 0) {
                        delete hashable.endpoints[key];
                    }
                }
                if (Object.keys(hashable.endpoints).length === 0) {
                    delete hashable.endpoints;
                }
            }
        }
        hashable = sortObject(hashable);
        return hashable;
    }
    /**
     * Returns a hash code that can be used to check whether a device config
     * has changed enough to require a re-interview.
     */
    async getHash(version = DeviceConfig.maxHashVersion) {
        // As of hash version 4, this includes:
        // - Association settings (maxNodes, isLifeline, multiChannel)
        // - Compat flags (addCCs, removeCCs, mapBasicSet, etc.)
        // - Proprietary config
        //
        // Configuration parameter metadata is NOT included, as changes
        // to it are applied dynamically without requiring re-interview.
        // Figure out what to hash
        const hashable = this.getHashable(version);
        // And create a "hash" from it. Older versions used a non-cryptographic hash,
        // newer versions compress a subset of the config file.
        let hash;
        if (version === 0) {
            const buffer = Bytes.from(JSON.stringify(hashable), "utf8");
            return await digest("md5", buffer);
        }
        else if (version === 1) {
            const buffer = Bytes.from(JSON.stringify(hashable), "utf8");
            return await digest("sha-256", buffer);
        }
        else {
            hash = deflateSync(Bytes.from(JSON.stringify(hashable), "utf8"), 
            // Try to make the hash as small as possible
            { level: 9, dictionary: deflateDict });
        }
        // Version the hash from v2 onwards, so we can change the format in the future
        const prefixBytes = Bytes.from(`$v${version}$`, "utf8");
        return Bytes.concat([prefixBytes, hash]);
    }
    static get maxHashVersion() {
        return 4;
    }
    /**
     * Compares a cached device config hash against the current one.
     * @param currentHash must be at the latest hash version
     * @param cachedHash may be at any version and will be normalized for comparison
     */
    static areHashesEqual(currentHash, cachedHash) {
        const current = parseHash(currentHash);
        const cached = parseHash(cachedHash);
        // If one of the hashes could not be parsed, they are not equal
        if (!current || !cached)
            return false;
        // For legacy hashes, we only compare the hash data. We already make sure during
        // parsing of the cache files that we only need to compare hashes of the same version,
        // so simply comparing the contents is sufficient.
        if (current.version < 2 && cached.version < 2) {
            return Bytes.view(current.hashData).equals(cached.hashData);
        }
        // We take care during loading to downlevel the current config hash to legacy versions if needed.
        // If we end up with just one legacy hash here, something went wrong. Just bail in that case.
        if (current.version < 2 || cached.version < 2) {
            return false;
        }
        // The cached hash may need normalization (v2 fix, version upgrade)
        let hashable;
        try {
            hashable = JSON.parse(Bytes.view(inflateSync(Bytes.view(cached.hashData), { dictionary: deflateDict })).toString("utf8"));
        }
        catch {
            return false;
        }
        // Some Z-Wave JS versions had an issue where the optional "hidden"
        // property was included in the hashable, causing incorrect hashes.
        fixBrokenV2Hashable(hashable, cached.version);
        // If the cached hash has an older version, we need to upgrade it to
        // the current version. This is necessary because some of the version upgrades
        // remove the need for some information to be included in the hash, so
        // a newer version gets by with less information than an older version.
        upgradeHashable(hashable, cached.version, current.version);
        // Recompress and compare against the current hash
        const normalizedData = deflateSync(Bytes.from(JSON.stringify(hashable), "utf8"), { level: 9, dictionary: deflateDict });
        return Bytes.view(normalizedData).equals(current.hashData);
    }
}
function parseHash(hash) {
    const hashString = Bytes.view(hash).toString("utf8");
    const versionMatch = hashString.match(/^\$v(\d+)\$/);
    if (versionMatch) {
        // This is a versioned hash
        const version = parseInt(versionMatch[1], 10);
        const hashData = hash.subarray(
        // The prefix is ASCII, so this is safe to do even in the context of UTF-8
        versionMatch[0].length);
        return {
            version,
            hashData,
        };
    }
    // This is probably an unversioned legacy hash
    switch (hash.length) {
        case 16: // MD5
            return {
                version: 0,
                hashData: hash,
            };
        case 32: // SHA-256
            return {
                version: 1,
                hashData: hash,
            };
        default:
            // This is not a valid hash
            return undefined;
    }
}
export { parseHash as parseDeviceConfigHash };
/**
 * Fixes broken v2 hashables that incorrectly included `hidden: false`.
 * Mutates the hashable in place.
 */
function fixBrokenV2Hashable(hashable, version) {
    if (version !== 2)
        return;
    for (const ep of Object.values(hashable.endpoints ?? {})) {
        for (const param of ep.paramInformation ?? []) {
            if (param.hidden === false) {
                delete param.hidden;
            }
        }
    }
}
/**
 * Upgrades a hashable to a newer version by stripping fields that are
 * now applied dynamically. Mutates the hashable in place.
 */
function upgradeHashable(hashable, fromVersion, targetVersion) {
    if (fromVersion >= targetVersion)
        return;
    if (targetVersion >= 4 && fromVersion < 4 && hashable.endpoints) {
        for (const [key, ep] of Object.entries(hashable.endpoints)) {
            delete ep.paramInformation;
            if (Object.keys(ep).length === 0) {
                delete hashable.endpoints[key];
            }
        }
        if (Object.keys(hashable.endpoints).length === 0) {
            delete hashable.endpoints;
        }
        fromVersion = 4;
    }
}
//# sourceMappingURL=DeviceConfig.js.map