import { NodeType, Protocols, SecurityClass, ZWaveError, ZWaveErrorCodes, dskFromString, dskToString, securityClassOrder, } from "@zwave-js/core";
import { Bytes, getEnumMemberName, num2hex, pickDeep } from "@zwave-js/shared";
import { isArray, isObject } from "alcalzone-shared/typeguards";
import path from "pathe";
import { ProvisioningEntryStatus, } from "../controller/Inclusion.js";
import { DeviceClass } from "../node/DeviceClass.js";
import { InterviewStage } from "../node/_Types.js";
/**
 * Defines the keys that are used to store certain properties in the network cache.
 */
export const cacheKeys = {
    controller: {
        provisioningList: "controller.provisioningList",
        associations: (groupId) => `controller.associations.${groupId}`,
        securityKeys: (secClass) => `controller.securityKeys.${getEnumMemberName(SecurityClass, secClass)}`,
        securityKeysLongRange: (secClass) => `controller.securityKeyLongRange.${getEnumMemberName(SecurityClass, secClass)}`,
        privateKey: "controller.privateKey",
    },
    // TODO: somehow these functions should be combined with the pattern matching below
    node: (nodeId) => {
        const nodeBaseKey = `node.${nodeId}.`;
        return {
            _baseKey: nodeBaseKey,
            _securityClassBaseKey: `${nodeBaseKey}securityClasses`,
            _priorityReturnRouteBaseKey: `${nodeBaseKey}priorityReturnRoute`,
            interviewStage: `${nodeBaseKey}interviewStage`,
            deviceClass: `${nodeBaseKey}deviceClass`,
            isListening: `${nodeBaseKey}isListening`,
            isFrequentListening: `${nodeBaseKey}isFrequentListening`,
            isRouting: `${nodeBaseKey}isRouting`,
            supportedDataRates: `${nodeBaseKey}supportedDataRates`,
            protocolVersion: `${nodeBaseKey}protocolVersion`,
            nodeType: `${nodeBaseKey}nodeType`,
            supportsSecurity: `${nodeBaseKey}supportsSecurity`,
            supportsBeaming: `${nodeBaseKey}supportsBeaming`,
            securityClass: (secClass) => `${nodeBaseKey}securityClasses.${getEnumMemberName(SecurityClass, secClass)}`,
            dsk: `${nodeBaseKey}dsk`,
            endpoint: (index) => {
                const endpointBaseKey = `${nodeBaseKey}endpoint.${index}.`;
                const ccBaseKey = `${endpointBaseKey}commandClass.`;
                return {
                    _baseKey: endpointBaseKey,
                    _ccBaseKey: ccBaseKey,
                    commandClass: (ccId) => {
                        const ccAsHex = num2hex(ccId);
                        return `${ccBaseKey}${ccAsHex}`;
                    },
                };
            },
            hasSUCReturnRoute: `${nodeBaseKey}hasSUCReturnRoute`,
            priorityReturnRoute: (destinationNodeId) => `${nodeBaseKey}priorityReturnRoute.${destinationNodeId}`,
            prioritySUCReturnRoute: `${nodeBaseKey}priorityReturnRoute.SUC`,
            customReturnRoutes: (destinationNodeId) => `${nodeBaseKey}customReturnRoutes.${destinationNodeId}`,
            customSUCReturnRoutes: `${nodeBaseKey}customReturnRoutes.SUC`,
            defaultTransitionDuration: `${nodeBaseKey}defaultTransitionDuration`,
            defaultVolume: `${nodeBaseKey}defaultVolume`,
            lastSeen: `${nodeBaseKey}lastSeen`,
            deviceConfigHash: `${nodeBaseKey}deviceConfigHash`,
        };
    },
};
export const cacheKeyUtils = {
    nodeIdFromKey: (key) => {
        const match = /^node\.(?<nodeId>\d+)\./.exec(key);
        if (match) {
            return parseInt(match.groups.nodeId, 10);
        }
    },
    nodePropertyFromKey: (key) => {
        const match = /^node\.\d+\.(?<property>[^\.]+)$/.exec(key);
        return match?.groups?.property;
    },
    isEndpointKey: (key) => {
        return /endpoints\.(?<index>\d+)$/.test(key);
    },
    endpointIndexFromKey: (key) => {
        const match = /endpoints\.(?<index>\d+)$/.exec(key);
        if (match) {
            return parseInt(match.groups.index, 10);
        }
    },
    destinationFromPriorityReturnRouteKey: (key) => {
        const match = /\.priorityReturnRoute\.(?<nodeId>\d+)$/.exec(key);
        if (match) {
            return parseInt(match.groups.nodeId, 10);
        }
    },
};
function tryParseInterviewStage(value) {
    if ((typeof value === "string" || typeof value === "number")
        && value in InterviewStage) {
        return typeof value === "number"
            ? value
            : InterviewStage[value];
    }
}
function tryParseDeviceClass(value) {
    if (isObject(value)) {
        const { basic, generic, specific } = value;
        if (typeof basic === "number"
            && typeof generic === "number"
            && typeof specific === "number") {
            return new DeviceClass(basic, generic, specific);
        }
    }
}
function tryParseSecurityClasses(value) {
    if (isObject(value)) {
        const ret = new Map();
        for (const [key, val] of Object.entries(value)) {
            if (key in SecurityClass
                && typeof SecurityClass[key] === "number"
                && typeof val === "boolean") {
                ret.set(SecurityClass[key], val);
            }
        }
        return ret;
    }
}
function tryParseNodeType(value) {
    if (typeof value === "string" && value in NodeType) {
        return NodeType[value];
    }
}
function tryParseProvisioningList(value) {
    const ret = [];
    if (!isArray(value))
        return;
    for (const entry of value) {
        if (isObject(entry)
            && typeof entry.dsk === "string"
            && isArray(entry.securityClasses)
            // securityClasses are stored as strings, not the enum values
            && entry.securityClasses.every((s) => isSerializedSecurityClass(s))
            && (entry.requestedSecurityClasses == undefined
                || (isArray(entry.requestedSecurityClasses)
                    && entry.requestedSecurityClasses.every((s) => isSerializedSecurityClass(s))))
            // protocol and supportedProtocols are (supposed to be) stored as strings, not the enum values
            && (entry.protocol == undefined
                || isSerializedProtocol(entry.protocol))
            && (entry.supportedProtocols == undefined || (isArray(entry.supportedProtocols)
                && entry.supportedProtocols.every((s) => isSerializedProtocol(s))))
            && (entry.status == undefined
                || isSerializedProvisioningEntryStatus(entry.status))) {
            // This is at least a PlannedProvisioningEntry, maybe it is an IncludedProvisioningEntry
            if ("nodeId" in entry && typeof entry.nodeId !== "number") {
                return;
            }
            const parsed = {
                ...entry,
            };
            parsed.securityClasses = entry.securityClasses
                .map((s) => tryParseSerializedSecurityClass(s))
                .filter((s) => s !== undefined);
            if (entry.requestedSecurityClasses) {
                parsed.requestedSecurityClasses = entry.requestedSecurityClasses
                    .map((s) => tryParseSerializedSecurityClass(s))
                    .filter((s) => s !== undefined);
            }
            if (entry.status != undefined) {
                parsed.status = ProvisioningEntryStatus[entry.status];
            }
            if (entry.protocol != undefined) {
                parsed.protocol = tryParseSerializedProtocol(entry.protocol);
            }
            if (entry.supportedProtocols) {
                parsed.supportedProtocols = entry.supportedProtocols
                    .map((s) => tryParseSerializedProtocol(s))
                    .filter((s) => s !== undefined);
            }
            ret.push(parsed);
        }
        else {
            return;
        }
    }
    return ret;
}
function isSerializedSecurityClass(value) {
    // There was an error in previous iterations of the migration code, so we
    // now have to deal with the following variants:
    // 1. plain numbers representing a valid Security Class: 1
    // 2. strings representing a valid Security Class: "S2_Unauthenticated"
    // 3. strings represending a mis-formatted Security Class: "unknown (0xS2_Unauthenticated)"
    if (typeof value === "number" && value in SecurityClass)
        return true;
    if (typeof value === "string") {
        if (value.startsWith("unknown (0x") && value.endsWith(")")) {
            value = value.slice(11, -1);
        }
        if (value in SecurityClass
            && typeof SecurityClass[value] === "number") {
            return true;
        }
    }
    return false;
}
function tryParseSerializedSecurityClass(value) {
    // There was an error in previous iterations of the migration code, so we
    // now have to deal with the following variants:
    // 1. plain numbers representing a valid Security Class: 1
    // 2. strings representing a valid Security Class: "S2_Unauthenticated"
    // 3. strings represending a mis-formatted Security Class: "unknown (0xS2_Unauthenticated)"
    if (typeof value === "number" && value in SecurityClass)
        return value;
    if (typeof value === "string") {
        if (value.startsWith("unknown (0x") && value.endsWith(")")) {
            value = value.slice(11, -1);
        }
        if (value in SecurityClass
            && typeof SecurityClass[value] === "number") {
            return SecurityClass[value];
        }
    }
}
function isSerializedProvisioningEntryStatus(s) {
    return (typeof s === "string"
        && s in ProvisioningEntryStatus
        && typeof ProvisioningEntryStatus[s] === "number");
}
function isSerializedProtocol(s) {
    // The list of supported protocols has been around since before we started
    // saving them as their stringified variant, so we
    // now have to deal with the following variants:
    // 1. plain numbers representing a valid Protocol: 0
    // 2. strings representing a valid Protocols: "ZWave"
    if (typeof s === "number" && s in Protocols)
        return true;
    return (typeof s === "string"
        && s in Protocols
        && typeof Protocols[s] === "number");
}
function tryParseSerializedProtocol(value) {
    // The list of supported protocols has been around since before we started
    // saving them as their stringified variant, so we
    // now have to deal with the following variants:
    // 1. plain numbers representing a valid Protocol: 0
    // 2. strings representing a valid Protocols: "ZWave"
    if (typeof value === "number" && value in Protocols)
        return value;
    if (typeof value === "string") {
        if (value in Protocols
            && typeof Protocols[value] === "number") {
            return Protocols[value];
        }
    }
}
function tryParseDate(value) {
    // Dates are stored as timestamps
    if (typeof value === "number") {
        const ret = new Date(value);
        if (!isNaN(ret.getTime()))
            return ret;
    }
}
function tryParseAssociationAddress(value) {
    if (isObject(value)) {
        const { nodeId, endpoint } = value;
        if (typeof nodeId !== "number")
            return;
        if (endpoint !== undefined && typeof endpoint !== "number")
            return;
        return { nodeId, endpoint };
    }
}
function tryParseBuffer(value) {
    if (typeof value === "string") {
        try {
            return Bytes.from(value, "hex");
        }
        catch {
            // ignore
        }
    }
}
function tryParseBufferBase64(value) {
    if (typeof value === "string") {
        try {
            return Bytes.from(value, "base64");
        }
        catch {
            // ignore
        }
    }
}
export function deserializeNetworkCacheValue(key, value) {
    function ensureType(value, type) {
        if (typeof value === type)
            return value;
        throw new ZWaveError(`Incorrect type ${typeof value} for property "${key}"`, ZWaveErrorCodes.Driver_InvalidCache);
    }
    function fail() {
        throw new ZWaveError(`Failed to deserialize property "${key}"`, ZWaveErrorCodes.Driver_InvalidCache);
    }
    switch (cacheKeyUtils.nodePropertyFromKey(key)) {
        case "interviewStage": {
            value = tryParseInterviewStage(value);
            if (value)
                return value;
            fail();
        }
        case "deviceClass": {
            value = tryParseDeviceClass(value);
            if (value)
                return value;
            fail();
        }
        case "isListening":
        case "isRouting":
        case "hasSUCReturnRoute":
            return ensureType(value, "boolean");
        case "isFrequentListening": {
            switch (value) {
                case "1000ms":
                case true:
                    return "1000ms";
                case "250ms":
                    return "250ms";
                case false:
                    return false;
            }
            fail();
        }
        case "dsk": {
            if (typeof value === "string") {
                return dskFromString(value);
            }
            fail();
        }
        case "supportsSecurity":
            return ensureType(value, "boolean");
        case "supportsBeaming":
            try {
                return ensureType(value, "boolean");
            }
            catch {
                return ensureType(value, "string");
            }
        case "protocolVersion":
            return ensureType(value, "number");
        case "nodeType": {
            value = tryParseNodeType(value);
            if (value)
                return value;
            fail();
        }
        case "supportedDataRates": {
            if (isArray(value)
                && value.every((r) => typeof r === "number")) {
                return value;
            }
            fail();
        }
        case "lastSeen": {
            value = tryParseDate(value);
            if (value)
                return value;
            fail();
        }
        case "deviceConfigHash": {
            if (typeof value !== "string")
                fail();
            const versionMatch = value.match(/^\$v\d+\$/)?.[0];
            if (versionMatch) {
                // Versioned hash, stored as base64, preserve the version prefix
                value = tryParseBufferBase64(value.slice(versionMatch.length));
                if (value) {
                    value = Bytes.concat([
                        Bytes.from(versionMatch, "utf8"),
                        value,
                    ]);
                }
            }
            else {
                // Legacy hash, no version prefix, stored as hex
                value = tryParseBuffer(value);
            }
            if (value)
                return value;
            fail();
        }
    }
    // Other properties
    if (key.startsWith("controller.associations.")) {
        value = tryParseAssociationAddress(value);
        if (value)
            return value;
        fail();
    }
    else if (key.startsWith("controller.securityKeys.")) {
        value = tryParseBuffer(value);
        if (value)
            return value;
        fail();
    }
    switch (key) {
        case cacheKeys.controller.provisioningList: {
            value = tryParseProvisioningList(value);
            if (value)
                return value;
            fail();
        }
        case cacheKeys.controller.privateKey: {
            value = tryParseBuffer(value);
            if (value)
                return value;
            fail();
        }
    }
    return value;
}
export function serializeNetworkCacheValue(key, value) {
    // Node-specific properties
    switch (cacheKeyUtils.nodePropertyFromKey(key)) {
        case "interviewStage": {
            return InterviewStage[value];
        }
        case "deviceClass": {
            const deviceClass = value;
            return {
                basic: deviceClass.basic,
                generic: deviceClass.generic.key,
                specific: deviceClass.specific.key,
            };
        }
        case "nodeType": {
            return NodeType[value];
        }
        case "securityClasses": {
            const ret = {};
            // Save security classes where they are known
            for (const secClass of securityClassOrder) {
                if (secClass in value) {
                    ret[SecurityClass[secClass]] = value[secClass];
                }
            }
            return ret;
        }
        case "dsk": {
            return dskToString(value);
        }
        case "lastSeen": {
            // Dates are stored as timestamps
            return value.getTime();
        }
        case "deviceConfigHash": {
            // Preserve the version prefix if it exists
            const valueAsString = Bytes.view(value).toString("utf8");
            const versionMatch = valueAsString.match(/^\$v\d+\$/)?.[0];
            if (versionMatch) {
                return versionMatch
                    + Bytes.view(value).subarray(versionMatch.length).toString("base64");
            }
            else {
                // For lecacy hashes, just return the hex representation
                return Bytes.view(value).toString("hex");
            }
        }
    }
    // Other dynamic properties
    if (key.startsWith("controller.securityKeys.")) {
        return Bytes.view(value).toString("hex");
    }
    // Other fixed properties
    switch (key) {
        case cacheKeys.controller.provisioningList: {
            const ret = [];
            for (const entry of value) {
                const serialized = { ...entry };
                serialized.securityClasses = entry.securityClasses.map((c) => getEnumMemberName(SecurityClass, c));
                if (entry.requestedSecurityClasses) {
                    serialized.requestedSecurityClasses = entry
                        .requestedSecurityClasses.map((c) => getEnumMemberName(SecurityClass, c));
                }
                if (entry.status != undefined) {
                    serialized.status = getEnumMemberName(ProvisioningEntryStatus, entry.status);
                }
                if (entry.protocol != undefined) {
                    serialized.protocol = getEnumMemberName(Protocols, entry.protocol);
                }
                if (entry.supportedProtocols != undefined) {
                    serialized.supportedProtocols = entry.supportedProtocols
                        .map((p) => getEnumMemberName(Protocols, p));
                }
                ret.push(serialized);
            }
            return ret;
        }
        case cacheKeys.controller.privateKey: {
            return Bytes.view(value).toString("hex");
        }
    }
    return value;
}
/** Defines the JSON paths that were used to store certain properties in the legacy network cache */
const legacyPaths = {
    // These seem to duplicate the ones in cacheKeys, but this allows us to change
    // something in the future without breaking migration
    controller: {
        provisioningList: "controller.provisioningList",
    },
    node: {
        // These are relative to the node object
        interviewStage: `interviewStage`,
        deviceClass: `deviceClass`,
        isListening: `isListening`,
        isFrequentListening: `isFrequentListening`,
        isRouting: `isRouting`,
        supportedDataRates: `supportedDataRates`,
        protocolVersion: `protocolVersion`,
        nodeType: `nodeType`,
        supportsSecurity: `supportsSecurity`,
        supportsBeaming: `supportsBeaming`,
        securityClasses: `securityClasses`,
        dsk: `dsk`,
    },
    commandClass: {
        // These are relative to the commandClasses object
        name: `name`,
        endpoint: (index) => `endpoints.${index}`,
    },
};
export async function migrateLegacyNetworkCache(homeId, networkCache, valueDB, fs, cacheDir) {
    const cacheFile = path.join(cacheDir, `${homeId.toString(16)}.json`);
    try {
        const stat = await fs.stat(cacheFile);
        if (!stat.isFile())
            return;
    }
    catch {
        // The file does not exist
        return;
    }
    const legacyContents = await fs.readFile(cacheFile);
    const legacy = JSON.parse(Bytes.view(legacyContents).toString("utf8"));
    const jsonl = networkCache;
    function tryMigrate(targetKey, source, sourcePath, converter) {
        let val = pickDeep(source, sourcePath);
        if (val != undefined && converter)
            val = converter(val);
        if (val != undefined)
            jsonl.set(targetKey, val);
    }
    // Translate all possible entries
    // Controller provisioning list
    tryMigrate(cacheKeys.controller.provisioningList, legacy, legacyPaths.controller.provisioningList, tryParseProvisioningList);
    // All nodes, ...
    if (isObject(legacy.nodes)) {
        for (const node of Object.values(legacy.nodes)) {
            if (!isObject(node) || typeof node.id !== "number")
                continue;
            const nodeCacheKeys = cacheKeys.node(node.id);
            // ... their properties
            tryMigrate(nodeCacheKeys.interviewStage, node, legacyPaths.node.interviewStage, tryParseInterviewStage);
            tryMigrate(nodeCacheKeys.deviceClass, node, legacyPaths.node.deviceClass, (v) => tryParseDeviceClass(v));
            tryMigrate(nodeCacheKeys.isListening, node, legacyPaths.node.isListening);
            tryMigrate(nodeCacheKeys.isFrequentListening, node, legacyPaths.node.isFrequentListening);
            tryMigrate(nodeCacheKeys.isRouting, node, legacyPaths.node.isRouting);
            tryMigrate(nodeCacheKeys.supportedDataRates, node, legacyPaths.node.supportedDataRates);
            tryMigrate(nodeCacheKeys.protocolVersion, node, legacyPaths.node.protocolVersion);
            tryMigrate(nodeCacheKeys.nodeType, node, legacyPaths.node.nodeType, tryParseNodeType);
            tryMigrate(nodeCacheKeys.supportsSecurity, node, legacyPaths.node.supportsSecurity);
            tryMigrate(nodeCacheKeys.supportsBeaming, node, legacyPaths.node.supportsBeaming);
            // Convert security classes to single entries
            const securityClasses = tryParseSecurityClasses(pickDeep(node, legacyPaths.node.securityClasses));
            if (securityClasses) {
                for (const [secClass, val] of securityClasses) {
                    jsonl.set(nodeCacheKeys.securityClass(secClass), val);
                }
            }
            tryMigrate(nodeCacheKeys.dsk, node, legacyPaths.node.dsk, dskFromString);
            // ... and command classes
            // The nesting was inverted from the legacy cache: node -> EP -> CCs
            // as opposed to node -> CC -> EPs
            if (isObject(node.commandClasses)) {
                for (const [ccIdHex, cc] of Object.entries(node.commandClasses)) {
                    const ccId = parseInt(ccIdHex, 16);
                    if (isObject(cc.endpoints)) {
                        for (const endpointId of Object.keys(cc.endpoints)) {
                            const endpointIndex = parseInt(endpointId, 10);
                            const cacheKey = nodeCacheKeys
                                .endpoint(endpointIndex)
                                .commandClass(ccId);
                            tryMigrate(cacheKey, cc, legacyPaths.commandClass.endpoint(endpointIndex));
                        }
                    }
                }
            }
            // In addition, try to move the hacky value ID for hasSUCReturnRoute from the value DB to the network cache
            const dbKey = JSON.stringify({
                nodeId: node.id,
                commandClass: -1,
                endpoint: 0,
                property: "hasSUCReturnRoute",
            });
            if (valueDB.has(dbKey)) {
                const hasSUCReturnRoute = valueDB.get(dbKey);
                valueDB.delete(dbKey);
                jsonl.set(nodeCacheKeys.hasSUCReturnRoute, hasSUCReturnRoute);
            }
        }
    }
}
//# sourceMappingURL=NetworkCache.js.map