// https://developer.scrypted.app/#getting-started
import sdk, { DeviceProvider, ScryptedDeviceBase, ScryptedInterface, Setting, Settings } from "@scrypted/sdk";
import { CommandClassInfo, getCommandClass, getCommandClassIndex } from "./CommandClasses";
import { ZwaveDeviceBase } from "./CommandClasses/ZwaveDeviceBase";
import { getHash, getNodeHash, getInstanceHash } from "./Types";
import debounce from "lodash/debounce";
import { Driver, Endpoint, ZWaveController, ZWaveNode, InclusionUserCallbacks, InclusionGrant, InclusionStrategy, NodeStatus, InclusionState } from "zwave-js";
import { ValueID, CommandClasses } from "@zwave-js/core"
import { randomBytes } from "crypto";
import path from "path";
import { isHex } from "./hex";

const { log, deviceManager } = sdk;

export enum NodeLiveness {
    Live,
    Query,
    Dead,

    // internal state
    QueryLive,
    QueryDead,
}

class NodeLivenessInfo {
    liveness: NodeLiveness;
    time: number = Date.now();
    checker: Function;

    updateLiveness(liveness: NodeLiveness): boolean {
        this.time = Date.now();
        if (this.liveness == liveness)
            return false;
        this.liveness = liveness;
        return true;
    }
}

export class ZwaveControllerProvider extends ScryptedDeviceBase implements DeviceProvider, Settings {
    devices: Object = {};
    nodeLiveness: Object = {};
    driver: Driver;
    controller: ZWaveController;
    driverReady: Promise<void>;
    dskDeferred: {reject: any, resolve: any};

    constructor() {
        super();

        this.startDriver();
    }

    startDriver() {
        this.driverReady = new Promise((resolve, reject) => {
            let networkKey = this.storage.getItem('networkKey');
            let s2AccessControlKey = this.storage.getItem('s2AccessControlKey');
            let s2AuthenticatedKey = this.storage.getItem('s2AuthenticatedKey');
            let s2UnauthenticatedKey = this.storage.getItem('s2UnauthenticatedKey');

            // 1/17/2022: the network key was stored as base64, but for consistency with HA
            // and others, it was switched to hex. this is the data migration.
            if (!isHex(networkKey) && networkKey) {
                networkKey = Buffer.from(networkKey, 'base64').toString('hex');
                this.storage.setItem('networkKey', networkKey);
            }

            if (!networkKey) {
                networkKey = randomBytes(16).toString('hex').toUpperCase();
                this.storage.setItem('networkKey', networkKey);
                this.log.a('No Network Key was present, so a random one was generated. You can change the Network Key in Settings.')
            }

            if (!s2AccessControlKey) {
                s2AccessControlKey = randomBytes(16).toString('hex').toUpperCase();
                this.storage.setItem('s2AccessControlKey', s2AccessControlKey);
                this.log.a('No S2 Access Control Key was present, so a random one was generated. You can change the S2 Access Control Key in Settings.');
            }

            if (!s2AuthenticatedKey) {
                s2AuthenticatedKey = randomBytes(16).toString('hex').toUpperCase();
                this.storage.setItem('s2AuthenticatedKey', s2AuthenticatedKey);
                this.log.a('No S2 Authenticated Key was present, so a random one was generated. You can change the S2 Access Control Key in Settings.');
            }

            if (!s2UnauthenticatedKey) {
                s2UnauthenticatedKey = randomBytes(16).toString('hex').toUpperCase();
                this.storage.setItem('s2UnauthenticatedKey', s2UnauthenticatedKey);
                this.log.a('No S2 Unauthenticated Key was present, so a random one was generated. You can change the S2 Unauthenticated Key in Settings.')
            }

            const cacheDir = path.join(process.env['SCRYPTED_PLUGIN_VOLUME'], 'cache');
            this.console.log(process.cwd());

            const driver = new Driver(this.storage.getItem('serialPort'), {
                securityKeys: {
                    S2_Unauthenticated: Buffer.from(s2UnauthenticatedKey, 'hex'),
                    S2_Authenticated: Buffer.from(s2AuthenticatedKey, 'hex'),
                    S2_AccessControl: Buffer.from(s2AccessControlKey, 'hex'),
                    S0_Legacy: Buffer.from(networkKey, 'hex')
                },
                storage: {
                    cacheDir,
                }
            });
            this.driver = driver;
            console.log(driver.cacheDir);

            driver.on("error", (e) => {
                driver.destroy().catch(() => {});
                console.error('driver error', e);
                reject(e);
            });

            driver.once("driver ready", () => {
                this.controller = driver.controller;
                const rebuildNode = async (node: ZWaveNode) => {
                    for (const endpoint of node.getAllEndpoints()) {
                        await this.rebuildInstance(endpoint);
                    }
                }

                const bindNode = (node: ZWaveNode) => {
                    node.on('value added', node => rebuildNode(node));
                    node.on('value removed', node => {
                        // node is being removed
                        if (!this.controller.nodes.get(node.id))
                            return;
                        rebuildNode(node);
                    });
                    node.on('value updated', (node, valueId) => {
                        const dirtyKey = getInstanceHash(this.controller.homeId, node.id, valueId.endpoint);
                        const device: ZwaveDeviceBase = this.devices[dirtyKey];
                        // device may not be in use by the system. watch for that.
                        if (device && deviceManager.getNativeIds().includes(device.nativeId)) {
                            this.updateNodeLiveness(device, NodeLiveness.Live);
                            device.onValueChanged(valueId);
                        }
                    });
                    node.on('interview completed', node => rebuildNode(node));
                }

                this.controller.on('node added', node => {
                    this.console.log('node added', node.nodeId);
                    bindNode(node);
                    rebuildNode(node);
                });

                this.controller.on('node removed', node => {
                    this.console.log('node removed', node?.nodeId);
                });

                driver.controller.nodes.forEach(node => {
                    this.console.log('node loaded', node.nodeId);
                    bindNode(node);
                    rebuildNode(node);
                });

                resolve();
                log.clearAlerts();
            });

            driver.start().catch(reject);
        });

        this.driverReady.catch(e => {
            log.a(`Zwave Driver startup error. Verify the Z-Wave USB stick is plugged in and the Serial Port setting is correct.`);
            this.console.error('zwave driver start error', e);
            this.console.log('This issue may be due to a driver or database lock. Retrying in 60 seconds.');
            setTimeout(() => this.startDriver(), 60000);
        });
    }

    async getSettings(): Promise<Setting[]> {
        return [
            {
                group: 'Inclusion',
                title: 'Inclusion State',
                key: 'inclusionState',
                readonly: true,
                value: InclusionState[this.controller?.inclusionState],
            },
            {
                group: 'Inclusion',
                title: 'Exclude Device',
                key: 'exclusion',
                type: 'button',
                description: 'Enter exclusion mode and remove devices.',
            },
            {
                group: 'Inclusion',
                title: 'Include Device',
                key: 'inclusion',
                type: 'button',
                description: 'Enter inclusion mode and add devices.',
            },
            {
                group: 'Inclusion',
                key: 'confirmPin',
                title: 'Confirm PIN',
                description: 'Some devices will require confirmation of a PIN while including them. Enter the PIN here when prompted.',
            },
            {
                group: 'Network',
                title: 'Healing State',
                key: 'healingState',
                readonly: true,
                value: this.controller?.isHealNetworkActive ? 'Healing' : 'Not Healing',
            },
            {
                group: 'Network',
                title: 'Heal Network',
                key: 'heal',
                type: 'button',
                description: 'Heal the Z-Wave Network. This operation may take a long time and the network may become unreponsive while in progress.',
            },
            {
                group: 'Adapter',
                title: 'Serial Port',
                key: 'serialPort',
                value: this.storage.getItem('serialPort'),
                description: 'Serial Port path or COM Port name',
                placeholder: '/dev/tty.usbmodem14501',
            },
            {
                group: 'Adapter',
                title: 'Network Key',
                key: 'networkKey',
                value: this.storage.getItem('networkKey'),
                description: 'The 16 byte hex encoded Network Security Key',
            },
            {
                group: 'Adapter',
                title: 'S2 Access Control Key',
                key: 's2AccessControlKey',
                value: this.storage.getItem('s2AccessControlKey'),
                description: 'The 16 byte hex encoded S2 Access Control Key',
            },
            {
                group: 'Adapter',
                title: 'S2 Authenticated Key',
                key: 's2AuthenticatedKey',
                value: this.storage.getItem('s2AuthenticatedKey'),
                description: 'The 16 byte hex encoded S2 Authenticated Key',
            },
            {
                group: 'Adapter',
                title: 'S2 Unauthenticated Key',
                key: 's2UnauthenticatedKey',
                value: this.storage.getItem('s2UnauthenticatedKey'),
                description: 'The 16 byte hex encoded S2 Unauthenticated Key',
            },
        ]
    }

    async stopOperations() {
        this.controller.stopHealingNetwork();
        await this.controller.stopExclusion();
        await this.controller.stopInclusion();
    }

    async inclusion() {
        const userCallbacks: InclusionUserCallbacks = {
            grantSecurityClasses: async (requested: InclusionGrant): Promise<false | InclusionGrant> => {
                this.console.log('grantSecurityClasses');
                return requested;
            },
            validateDSKAndEnterPIN: async (dsk: string) => {
                this.console.log('dsk received', dsk);
                this.log.a('Please enter the pairing DSK to confirm device enrollment.');
                return new Promise((resolve, reject) => {
                    if (this.dskDeferred) {
                        this.dskDeferred.reject(new Error('new dsk received'));
                        this.dskDeferred = undefined;
                    }
                    this.dskDeferred = {
                        resolve,
                        reject,
                    };
                });
            },
            abort: function (): void {
                this.console.log('inclusion aborted');
            }
        }
        await this.stopOperations();
        const including = await this.driver.controller.beginInclusion({
            userCallbacks,
            strategy: InclusionStrategy.Default,
        });
        this.log.a('Including devices for 5 minutes.');
        this.console.log('including', including);

        setTimeout(() => this.driver.controller.stopInclusion(), 300000);
    }

    async exclusion() {
        await this.stopOperations();
        const excluding = await this.driver.controller.beginExclusion();
        this.log.a('Excluding devices for 5 minutes.');
        this.console.log('excluding', excluding);
        setTimeout(() => this.driver.controller.stopExclusion(), 300000);
    }

    async healNetwork() {
        await this.stopOperations();
        const healing = this.controller.beginHealingNetwork();
        this.console.log('healing network', healing);
    }

    async putSetting(key: string, value: string | number | boolean) {
        if (key === 'inclusion') {
            this.inclusion();
            return;
        }
        if (key === 'exclusion') {
            this.exclusion();
            return;
        }
        if (key === 'confirmPin') {
            this.dskDeferred?.resolve(value.toString());
            this.dskDeferred = undefined;
            return;
        }
        if (key === 'heal') {
            this.healNetwork();
            return;
        }

        this.storage.setItem(key, value as string);

        await this.driver?.destroy();
        this.driver = undefined;
        this.startDriver();
    }

    async discoverDevices(duration: number) {
    }

    async getDevice(nativeId: string) {
        await this.driverReady;
        return this.devices[nativeId];
    }

    async releaseDevice(id: string, nativeId: string): Promise<void> {
    }

    _addType(scryptedDevice: ZwaveDeviceBase, instance: Endpoint, type: CommandClassInfo, valueId: ValueID) {
        var interfaces = type.getInterfaces(instance.getNodeUnsafe(), valueId);
        if (!interfaces) {
            return;
        }

        var methods = Reflect.ownKeys(type.handlerClass.prototype).filter(v => v != 'constructor');

        for (var m of methods) {
            scryptedDevice[m] = type.handlerClass.prototype[m];
        }

        scryptedDevice.device.interfaces.push(...interfaces);
        scryptedDevice.commandClasses.push(type);
    }

    async rebuildInstance(instance: Endpoint) {
        const nativeId = getHash(this.controller, instance);
        let scryptedDevice: ZwaveDeviceBase = this.devices[nativeId];
        if (this.controller.nodes.get(instance.nodeId).status === NodeStatus.Dead) {
            scryptedDevice.log.a('Node is dead.');
        }
        if (!scryptedDevice) {
            scryptedDevice = new ZwaveDeviceBase(this.controller, instance);
            scryptedDevice.zwaveController = this;
            const node = instance.getNodeUnsafe();
            let name: string;
            if (node.supportsCC(CommandClasses['Node Naming and Location'])) {
                try {
                    const nodeNaming = instance.getNodeUnsafe().commandClasses["Node Naming and Location"];
                    name = await nodeNaming?.getName() || 'Z-Wave Device';
                }
                catch (e) {
                    // have seen this fail, even though it is supposedly available
                }
            }
            scryptedDevice.device = {
                name,
                interfaces: [],
                nativeId,
                type: undefined,
            };
        }

        for (let cc of instance.getSupportedCCInstances()) {
            var type = getCommandClass(cc.ccId);
            if (type) {
                this._addType(scryptedDevice, instance, type, null);
                continue;
            }
        }

        const values = instance.getNodeUnsafe().getDefinedValueIDs().filter(value => value.endpoint == instance.index);
        for (var value of values) {
            var type = getCommandClassIndex(value.commandClass, value.property as number);
            if (!type) {
                continue;
            }

            this._addType(scryptedDevice, instance, type, value);
        }

        if (!scryptedDevice.device.interfaces.length) {
            delete this.devices[nativeId];
            // remove?
            return;
        }
        this.devices[nativeId] = scryptedDevice;
        // Refresh is problematic. Perhaps another method on Online to do a real health check.
        scryptedDevice.device.interfaces.push(
            ScryptedInterface.Refresh,
            ScryptedInterface.Online,
            ScryptedInterface.Settings,
        );
        await deviceManager.onDeviceDiscovered(scryptedDevice.device);
        scryptedDevice.updateState();

        // todo: watch for name change and sync to zwave controller
        const node = instance.getNodeUnsafe();
        if (node.supportsCC(CommandClasses['Node Naming and Location'])) {
            try {
                const naming = instance.getNodeUnsafe().commandClasses?.['Node Naming and Location'];
                await naming?.setName(scryptedDevice.name);
            }
            catch (e) {
                // have seen this fail, even though it is supposedly available
            }
        }

        if (scryptedDevice.device.interfaces.includes(ScryptedInterface.Battery)) {
            scryptedDevice.instance.getNodeUnsafe().refreshCCValues(CommandClasses['Battery']);
        }
    }

    updateNodeLiveness(device: ZwaveDeviceBase, liveness: NodeLiveness) {
        var key = getNodeHash(this.controller, device.instance.getNodeUnsafe());
        var current: NodeLivenessInfo = this.nodeLiveness[key];

        if (!current) {
            current = new NodeLivenessInfo();
            current.liveness = liveness;
            this.nodeLiveness[key] = current;
            device.online = this.isNodeOnline(device.instance.getNodeUnsafe());
            return;
        }

        if (liveness == NodeLiveness.Live || liveness == NodeLiveness.Dead) {
            if (current.updateLiveness(liveness)) {
                device.online = this.isNodeOnline(device.instance.getNodeUnsafe());
            }
            return;
        }

        // if the existing liveness is too old, this node's liveness status gets downgraded
        if (current.time < Date.now() - 60000) {
            if (current.liveness == null)
                current.liveness = NodeLiveness.Live;
            switch (current.liveness) {
                case NodeLiveness.Live:
                case NodeLiveness.Query:
                    liveness = NodeLiveness.QueryLive;
                    break;
                case NodeLiveness.QueryLive:
                    liveness = NodeLiveness.QueryDead;
                    break;
                case NodeLiveness.QueryDead:
                    liveness = NodeLiveness.Dead;
                    break;
                default:
                    liveness = NodeLiveness.Dead;
            }
            device.log.w("Node has been downgraded: " + liveness);
            if (current.updateLiveness(liveness))
                device.online = this.isNodeOnline(device.instance.getNodeUnsafe());
        }
        else if (current.liveness == NodeLiveness.Live) {
            device.log.i("Node was recently online. Stopping healthcheck until a later query.");
            return;
        }

        // dead is dead. wait for it to come back. no more health checking.
        if (liveness == NodeLiveness.Dead) {
            device.log.e("Node is not online. Stopping health checks until it returns.");
            return;
        }

        // check the health again in a bit.
        if (!current.checker) {
            current.checker = debounce(() => {
                this.updateNodeLiveness(device, NodeLiveness.Query);
            }, 30000);
        }
        current.checker();
    }

    isNodeOnline(node: ZWaveNode): boolean {
        var info: NodeLivenessInfo = this.nodeLiveness[getNodeHash(this.controller, node)];
        if (info == null || info.liveness == null || info.liveness == NodeLiveness.Live || info.liveness == NodeLiveness.QueryLive || info.liveness == NodeLiveness.Query)
            return true;

        return false;
    }
}

export default new ZwaveControllerProvider();
