import {KeyValue, DatabaseEntry, DeviceType} from '../tstype';
import {Events as AdapterEvents} from '../../adapter';
import Endpoint from './endpoint';
import Entity from './entity';
import {Wait} from '../../utils';
import Debug from "debug";
import * as Zcl from '../../zcl';

/**
 * @ignore
 */
const OneJanuary2000 = new Date('January 01, 2000 00:00:00 UTC+00:00').getTime();

const debug = {
    error: Debug('zigbee-herdsman:controller:device:error'),
    log: Debug('zigbee-herdsman:controller:device:log'),
};

interface LQI {
    neighbors: {
        ieeeAddr: string; networkAddress: number; linkquality: number;
        relationship: number; depth: number;
    }[];
}

interface RoutingTable {
    table: {destinationAddress: number; status: string; nextHop: number}[];
}

class Device extends Entity {
    private readonly ID: number;
    private _applicationVersion?: number;
    private _dateCode?: string;
    private _endpoints: Endpoint[];
    private _hardwareVersion?: number;
    private _ieeeAddr: string;
    private _interviewCompleted: boolean;
    private _interviewing: boolean;
    private _lastSeen: number;
    private _manufacturerID?: number;
    private _manufacturerName?: string;
    private _modelID?: string;
    private _networkAddress: number;
    private _powerSource?: string;
    private _softwareBuildID?: string;
    private _stackVersion?: number;
    private _type?: DeviceType;
    private _zclVersion?: number;

    // Getters/setters
    get ieeeAddr(): string {return this._ieeeAddr;}
    set ieeeAddr(ieeeAddr: string) {this._ieeeAddr = ieeeAddr;}
    get applicationVersion(): number {return this._applicationVersion;}
    set applicationVersion(applicationVersion: number) {this._applicationVersion = applicationVersion;}
    get endpoints(): Endpoint[] {return this._endpoints;}
    get interviewCompleted(): boolean {return this._interviewCompleted;}
    get interviewing(): boolean {return this._interviewing;}
    get lastSeen(): number {return this._lastSeen;}
    get manufacturerID(): number {return this._manufacturerID;}
    set type(type: DeviceType) {this._type = type;}
    get type(): DeviceType {return this._type;}
    get dateCode(): string {return this._dateCode;}
    set dateCode(dateCode: string) {this._dateCode = dateCode;}
    set hardwareVersion(hardwareVersion: number) {this._hardwareVersion = hardwareVersion;}
    get hardwareVersion(): number {return this._hardwareVersion;}
    get manufacturerName(): string {return this._manufacturerName;}
    set manufacturerName(manufacturerName: string) {this._manufacturerName = manufacturerName;}
    set modelID(modelID: string) {this._modelID = modelID;}
    get modelID(): string {return this._modelID;}
    get networkAddress(): number {return this._networkAddress;}
    set networkAddress(networkAddress: number) {
        this._networkAddress = networkAddress;
        for (const endpoint of this._endpoints) {
            endpoint.deviceNetworkAddress = networkAddress;
        }
    }
    get powerSource(): string {return this._powerSource;}
    set powerSource(powerSource: string) {
        this._powerSource = typeof powerSource === 'number' ? Zcl.PowerSource[powerSource] : powerSource;
    }
    get softwareBuildID(): string {return this._softwareBuildID;}
    set softwareBuildID(softwareBuildID: string) {this._softwareBuildID = softwareBuildID;}
    get stackVersion(): number {return this._stackVersion;}
    set stackVersion(stackVersion: number) {this._stackVersion = stackVersion;}
    get zclVersion(): number {return this._zclVersion;}
    set zclVersion(zclVersion: number) {this._zclVersion = zclVersion;}

    private meta: KeyValue;

    // This lookup contains all devices that are queried from the database, this is to ensure that always
    // the same instance is returned.
    private static devices: {[ieeeAddr: string]: Device} = null;

    public static readonly ReportablePropertiesMapping: {[s: string]: {
        set: (value: string | number, device: Device) => void;
        key: 'modelID' | 'manufacturerName' | 'applicationVersion' | 'zclVersion' | 'powerSource' | 'stackVersion' |
            'dateCode' | 'softwareBuildID' | 'hardwareVersion';
    };} = {
        modelId: {key: 'modelID', set: (v: string, d: Device): void => {d.modelID = v;}},
        manufacturerName: {key: 'manufacturerName', set: (v: string, d: Device): void => {d.manufacturerName = v;}},
        powerSource: {key: 'powerSource', set: (v: string, d: Device): void => {d.powerSource = v;}},
        zclVersion: {key: 'zclVersion', set: (v: number, d: Device): void => {d.zclVersion = v;}},
        appVersion: {key: 'applicationVersion', set: (v: number, d: Device): void => {d.applicationVersion = v;}},
        stackVersion: {key: 'stackVersion', set: (v: number, d: Device): void => {d.stackVersion = v;}},
        hwVersion: {key: 'hardwareVersion', set: (v: number, d: Device): void => {d.hardwareVersion = v;}},
        dateCode: {key: 'dateCode', set: (v: string, d: Device): void => {d.dateCode = v;}},
        swBuildId: {key: 'softwareBuildID', set: (v: string, d: Device): void => {d.softwareBuildID = v;}},
    };

    private constructor(
        ID: number, type: DeviceType, ieeeAddr: string, networkAddress: number,
        manufacturerID: number, endpoints: Endpoint[], manufacturerName: string,
        powerSource: string, modelID: string, applicationVersion: number, stackVersion: number, zclVersion: number,
        hardwareVersion: number, dateCode: string, softwareBuildID: string, interviewCompleted: boolean, meta: KeyValue,
        lastSeen: number,
    ) {
        super();
        this.ID = ID;
        this._type = type;
        this.ieeeAddr = ieeeAddr;
        this._networkAddress = networkAddress;
        this._manufacturerID = manufacturerID;
        this._endpoints = endpoints;
        this._manufacturerName = manufacturerName;
        this._powerSource = powerSource;
        this._modelID = modelID;
        this._applicationVersion = applicationVersion;
        this._stackVersion = stackVersion;
        this._zclVersion = zclVersion;
        this.hardwareVersion = hardwareVersion;
        this._dateCode = dateCode;
        this._softwareBuildID = softwareBuildID;
        this._interviewCompleted = interviewCompleted;
        this._interviewing = false;
        this.meta = meta;
        this._lastSeen = lastSeen;
    }

    public async createEndpoint(ID: number): Promise<Endpoint> {
        if (this.getEndpoint(ID)) {
            throw new Error(`Device '${this.ieeeAddr}' already has an endpoint '${ID}'`);
        }

        const endpoint = Endpoint.create(ID, undefined, undefined, [], [], this.networkAddress, this.ieeeAddr);
        this.endpoints.push(endpoint);
        this.save();
        return endpoint;
    }

    public getEndpoint(ID: number): Endpoint {
        return this.endpoints.find((e): boolean => e.ID === ID);
    }

    // There might be multiple endpoints with same DeviceId but it is not supported and first endpoint is returned
    public getEndpointByDeviceType(deviceType: string): Endpoint {
        const deviceID = Zcl.EndpointDeviceType[deviceType];
        return this.endpoints.find((d): boolean => d.deviceID === deviceID);
    }

    public updateLastSeen(): void {
        this._lastSeen = Date.now();
    }

    public async onZclData(dataPayload: AdapterEvents.ZclDataPayload, endpoint: Endpoint): Promise<void> {
        const frame = dataPayload.frame;

        // Respond to enroll requests
        if (frame.isSpecific() && frame.isCluster('ssIasZone') && frame.isCommand('enrollReq')) {
            debug.log(`IAS - '${this.ieeeAddr}' responding to enroll response`);
            const payload = {enrollrspcode: 0, zoneid: 23};
            await endpoint.command('ssIasZone', 'enrollRsp', payload, {disableDefaultResponse: true});
        }

        // Reponse to genTime reads
        if (frame.isGlobal() && frame.isCluster('genTime') && frame.isCommand('read')) {
            const time = Math.round(((new Date()).getTime() - OneJanuary2000) / 1000);
            const response: KeyValue = {};
            const values: KeyValue = {
                timeStatus: 3, // Time-master + synchronised
                time: time,
                timeZone: ((new Date()).getTimezoneOffset() * -1) * 60,
                localTime: time - (new Date()).getTimezoneOffset() * 60,
            };

            const cluster = Zcl.Utils.getCluster('genTime');
            for (const entry of frame.Payload) {
                const name = cluster.getAttribute(entry.attrId).name;
                if (values.hasOwnProperty(name)) {
                    response[name] = values[name];
                } else {
                    debug.error(`'${this.ieeeAddr}' read unsupported attribute from genTime '${name}'`);
                }
            }

            try {
                await endpoint.readResponse(frame.Cluster.ID, frame.Header.transactionSequenceNumber, response);
            } catch (error) {
                debug.error(`genTime response to ${this.ieeeAddr} failed`);
            }
        }

        // Send a default response if necessary.
        const isDefaultResponse = frame.isGlobal() && frame.getCommand().name === 'defaultRsp';
        const commandHasResponse = frame.getCommand().hasOwnProperty('response');
        if (!frame.Header.frameControl.disableDefaultResponse && !isDefaultResponse && !commandHasResponse) {
            try {
                await endpoint.defaultResponse(
                    frame.getCommand().ID, 0, frame.Cluster.ID, frame.Header.transactionSequenceNumber,
                );
            } catch (error) {
                debug.error(`Default response to ${this.ieeeAddr} failed`);
            }
        }
    }

    /*
     * CRUD
     */

    private static fromDatabaseEntry(entry: DatabaseEntry): Device {
        const networkAddress = entry.nwkAddr;
        const ieeeAddr = entry.ieeeAddr;
        const endpoints = Object.values(entry.endpoints).map((e): Endpoint => {
            return Endpoint.fromDatabaseRecord(e, networkAddress, ieeeAddr);
        });

        const meta = entry.meta ? entry.meta : {};

        if (entry.type === 'Group') {
            throw new Error('Cannot load device from group');
        }

        return new Device(
            entry.id, entry.type, ieeeAddr, networkAddress, entry.manufId, endpoints,
            entry.manufName, entry.powerSource, entry.modelId, entry.appVersion,
            entry.stackVersion, entry.zclVersion, entry.hwVersion, entry.dateCode, entry.swBuildId,
            entry.interviewCompleted, meta, entry.lastSeen || null,
        );
    }

    private toDatabaseEntry(): DatabaseEntry {
        const epList = this.endpoints.map((e): number => e.ID);
        const endpoints: KeyValue = {};
        for (const endpoint of this.endpoints) {
            endpoints[endpoint.ID] = endpoint.toDatabaseRecord();
        }

        return {
            id: this.ID, type: this.type, ieeeAddr: this.ieeeAddr, nwkAddr: this.networkAddress,
            manufId: this.manufacturerID, manufName: this.manufacturerName, powerSource: this.powerSource,
            modelId: this.modelID, epList, endpoints, appVersion: this.applicationVersion,
            stackVersion: this.stackVersion, hwVersion: this.hardwareVersion, dateCode: this.dateCode,
            swBuildId: this.softwareBuildID, zclVersion: this.zclVersion, interviewCompleted: this.interviewCompleted,
            meta: this.meta, lastSeen: this.lastSeen,
        };
    }

    public save(): void {
        Entity.database.update(this.toDatabaseEntry());
    }

    private static loadFromDatabaseIfNecessary(): void {
        if (!Device.devices) {
            Device.devices = {};
            const entries = Entity.database.getEntries(['Coordinator', 'EndDevice', 'Router', 'GreenPower']);
            for (const entry of entries) {
                const device = Device.fromDatabaseEntry(entry);
                Device.devices[device.ieeeAddr] = device;
            }
        }
    }

    public static byIeeeAddr(ieeeAddr: string): Device {
        Device.loadFromDatabaseIfNecessary();
        return Device.devices[ieeeAddr];
    }

    public static byNetworkAddress(networkAddress: number): Device {
        Device.loadFromDatabaseIfNecessary();
        return Object.values(Device.devices).find(d => d.networkAddress === networkAddress);
    }

    public static byType(type: DeviceType): Device[] {
        Device.loadFromDatabaseIfNecessary();
        return Object.values(Device.devices).filter(d => d.type === type);
    }

    public static all(): Device[] {
        Device.loadFromDatabaseIfNecessary();
        return Object.values(Device.devices);
    }

    public static create(
        type: DeviceType, ieeeAddr: string, networkAddress: number,
        manufacturerID: number, manufacturerName: string,
        powerSource: string, modelID: string, interviewCompleted: boolean,
        endpoints: {
            ID: number; profileID: number; deviceID: number; inputClusters: number[]; outputClusters: number[];
        }[],
    ): Device {
        Device.loadFromDatabaseIfNecessary();
        if (Device.devices[ieeeAddr]) {
            throw new Error(`Device with ieeeAddr '${ieeeAddr}' already exists`);
        }

        const endpointsMapped = endpoints.map((e): Endpoint => {
            return Endpoint.create(
                e.ID, e.profileID, e.deviceID, e.inputClusters, e.outputClusters, networkAddress, ieeeAddr
            );
        });

        const ID = Entity.database.newID();
        const device = new Device(
            ID, type, ieeeAddr, networkAddress, manufacturerID, endpointsMapped, manufacturerName,
            powerSource, modelID, undefined, undefined, undefined, undefined, undefined, undefined,
            interviewCompleted, {}, null,
        );

        Entity.database.insert(device.toDatabaseEntry());
        Device.devices[device.ieeeAddr] = device;
        return device;
    }

    /*
     * Zigbee functions
     */

    public async interview(): Promise<void> {
        if (this.interviewing) {
            const message = `Interview - interview already in progress for '${this.ieeeAddr}'`;
            debug.log(message);
            throw new Error(message);
        }

        let error;
        this._interviewing = true;
        debug.log(`Interview - start device '${this.ieeeAddr}'`);

        try {
            await this.interviewInternal();
            debug.log(`Interview - completed for device '${this.ieeeAddr}'`);
            this._interviewCompleted = true;
        } catch (e) {
            if (this.interviewQuirks()) {
                debug.log(`Interview - completed for device '${this.ieeeAddr}' because of quirks ('${e}')`);
            } else {
                debug.log(`Interview - failed for device '${this.ieeeAddr}' with error '${e.stack}'`);
                error = e;
            }
        } finally {
            this._interviewing = false;
            this.save();
        }

        if (error) {
            throw error;
        }
    }

    private interviewQuirks(): boolean {
        // Some devices, e.g. Xiaomi end devices have a different interview procedure, after pairing they
        // report it's modelID trough a readResponse. The readResponse is received by the controller and set
        // on the device.
        const lookup: {[s: string]: {
            type: DeviceType; manufacturerID: number; manufacturerName: string; powerSource: string;
        };} = {
            'lumi\..*': {
                type: 'EndDevice', manufacturerID: 4151, manufacturerName: 'LUMI', powerSource: 'Battery'
            },
            'TERNCY-PP01': {
                type: 'EndDevice', manufacturerID: 4648, manufacturerName: 'TERNCY', powerSource: 'Battery'
            },
        };

        const match = Object.keys(lookup).find((key) => this.modelID && this.modelID.match(key));
        if (match) {
            const info = lookup[match];
            debug.log(`Interview procedure failed but got modelID matching '${match}', assuming interview succeeded`);
            this._type = this._type || info.type;
            this._manufacturerID = this._manufacturerID || info.manufacturerID;
            this._manufacturerName = this._manufacturerName || info.manufacturerName;
            this._powerSource = this._powerSource || info.powerSource;
            this._interviewing = false;
            this._interviewCompleted = true;
            this.save();
            return true;
        } else {
            return false;
        }
    }

    private async interviewInternal(): Promise<void> {
        const nodeDescriptorQuery = async (): Promise<void> => {
            const nodeDescriptor = await Entity.adapter.nodeDescriptor(this.networkAddress);
            this._manufacturerID = nodeDescriptor.manufacturerCode;
            this._type = nodeDescriptor.type;
            this.save();
            debug.log(`Interview - got node descriptor for device '${this.ieeeAddr}'`);
        };

        let gotNodeDescriptor = false;
        for (let attempt = 0; attempt < 6; attempt++) {
            try {
                await nodeDescriptorQuery();
                gotNodeDescriptor = true;
                break;
            } catch (error) {
                if (this.interviewQuirks()) {
                    debug.log(`Interview - completed for device '${this.ieeeAddr}' because of quirks ('${error}')`);
                    return;
                } else {
                    // Most of the times the first node descriptor query fails and the seconds one succeeds.
                    debug.log(
                        `Interview - node descriptor request failed for '${this.ieeeAddr}', attempt ${attempt + 1}`
                    );
                }
            }
        }
        if (!gotNodeDescriptor) {
            throw new Error(`Interview failed because can not get node descriptor ('${this.ieeeAddr}')`);
        }

        // e.g. Xiaomi Aqara Opple devices fail to respond to the first active endpoints request, therefore try 2 times
        // https://github.com/Koenkk/zigbee-herdsman/pull/103
        let activeEndpoints;
        for (let attempt = 0; attempt < 2; attempt++) {
            try {
                activeEndpoints = await Entity.adapter.activeEndpoints(this.networkAddress);
                break;
            } catch (error) {
                debug.log(`Interview - active endpoints request failed for '${this.ieeeAddr}', attempt ${attempt + 1}`);
            }
        }
        if (!activeEndpoints) {
            throw new Error(`Interview failed because can not get active endpoints ('${this.ieeeAddr}')`);
        }

        // Make sure that the endpoint are sorted.
        activeEndpoints.endpoints.sort();

        // Some devices, e.g. TERNCY return endpoint 0 in the active endpoints request.
        // This is not a valid endpoint number according to the ZCL, requesting a simple descriptor will result
        // into an error. Therefore we filter it, more info: https://github.com/Koenkk/zigbee-herdsman/issues/82
        this._endpoints = activeEndpoints.endpoints.filter((e) => e !== 0).map((e): Endpoint => {
            return Endpoint.create(e, undefined, undefined, [], [], this.networkAddress, this.ieeeAddr);
        });
        this.save();
        debug.log(`Interview - got active endpoints for device '${this.ieeeAddr}'`);

        debug.log(this.endpoints);
        for (const endpoint of this.endpoints) {
            const simpleDescriptor = await Entity.adapter.simpleDescriptor(this.networkAddress, endpoint.ID);
            endpoint.profileID = simpleDescriptor.profileID;
            endpoint.deviceID = simpleDescriptor.deviceID;
            endpoint.inputClusters = simpleDescriptor.inputClusters;
            endpoint.outputClusters = simpleDescriptor.outputClusters;
            debug.log(`Interview - got simple descriptor for endpoint '${endpoint.ID}' device '${this.ieeeAddr}'`);
            this.save();

            // Read attributes, nice to have but not required for succesfull pairing as most of the attributes
            // are not mandatory in ZCL specification.
            if (endpoint.supportsInputCluster('genBasic')) {
                for (const [key, item] of Object.entries(Device.ReportablePropertiesMapping)) {
                    if (!this[item.key]) {
                        try {
                            const result = await endpoint.read('genBasic', [key]);
                            item.set(result[key], this);
                            debug.log(`Interview - got '${item.key}' for device '${this.ieeeAddr}'`);
                        } catch (error) {
                            debug.log(
                                `Failed to read attribute '${item.key}' from endpoint '${endpoint.ID}' (${error})`
                            );
                        }
                    }
                }
            }
        }

        // Enroll IAS device
        for (const endpoint of this.endpoints.filter((e): boolean => e.supportsInputCluster('ssIasZone'))) {
            debug.log(`Interview - IAS - enrolling '${this.ieeeAddr}' endpoint '${endpoint.ID}'`);

            const stateBefore = await endpoint.read('ssIasZone', ['iasCieAddr', 'zoneState']);
            debug.log(`Interview - IAS - before enrolling state: '${JSON.stringify(stateBefore)}'`);

            // Do not enroll when device has already been enrolled
            const coordinator = Device.byType('Coordinator')[0];
            if (stateBefore.zoneState !== 1 || stateBefore.iasCieAddr !== coordinator.ieeeAddr) {
                debug.log(`Interview - IAS - not enrolled, enrolling`);

                await endpoint.write('ssIasZone', {'iasCieAddr': coordinator.ieeeAddr});
                debug.log(`Interview - IAS - wrote iasCieAddr`);

                // According to the ZCL, after the iasCieAddr is written, the device will do an
                // enroll request. This should be responded with an enroll response.
                // As some devices send these enroll requests randomly (while the iasCieAddr has not been written yet)
                // we always respond to these enroll requests in the onZclData() function.
                // Therefore we don't have to do it here anymore.
                // https://github.com/Koenkk/zigbee2mqtt/issues/3012
                let enrolled = false;
                for (let attempt = 0; attempt < 20; attempt++) {
                    await Wait(500);
                    const stateAfter = await endpoint.read('ssIasZone', ['iasCieAddr', 'zoneState']);
                    debug.log(`Interview - IAS - after enrolling state (${attempt}): '${JSON.stringify(stateAfter)}'`);
                    if (stateAfter.zoneState === 1) {
                        enrolled = true;
                        break;
                    }
                }

                if (enrolled) {
                    debug.log(`Interview - IAS successfully enrolled '${this.ieeeAddr}' endpoint '${endpoint.ID}'`);
                } else {
                    throw new Error(
                        `Interview failed because of failed IAS enroll (zoneState didn't change ('${this.ieeeAddr}')`
                    );
                }
            } else {
                debug.log(`Interview - IAS - already enrolled, skipping enroll`);
            }
        }
    }

    public async removeFromNetwork(): Promise<void> {
        await Entity.adapter.removeDevice(this.networkAddress, this.ieeeAddr);
        await this.removeFromDatabase();
    }

    public async removeFromDatabase(): Promise<void> {
        Device.loadFromDatabaseIfNecessary();

        for (const endpoint of this.endpoints) {
            endpoint.removeFromAllGroupsDatabase();
        }

        if (Entity.database.has(this.ID)) {
            Entity.database.remove(this.ID);
        }

        delete Device.devices[this.ieeeAddr];
    }

    public async lqi(): Promise<LQI> {
        return Entity.adapter.lqi(this.networkAddress);
    }

    public async routingTable(): Promise<RoutingTable> {
        return Entity.adapter.routingTable(this.networkAddress);
    }

    public async ping(): Promise<void> {
        // Zigbee does not have an official pining mechamism. Use a read request
        // of a mandatory basic cluster attribute to keep it as lightweight as
        // possible.
        await this.endpoints[0].read('genBasic', ['zclVersion'], {disableRecovery: true});
    }
}

export default Device;
