import {
    INodeType,
    INodeTypeDescription,
    INodeExecutionData,
    NodeOperationError,
    NodeConnectionType,
} from 'n8n-workflow';

// NOTE: We do NOT import IExecuteFunctions (you said it's not available).
// We'll use `this: any & OPCWrite` in execute.

import {
    OPCUAClient,
    ClientSession,
    AttributeIds,
    DataType,
    StatusCodes,
    // node-opcua typings provide these enums/constructs
} from 'node-opcua';

export class _OPCWrite implements INodeType {
    description: INodeTypeDescription = {
        displayName: 'OPCWrite',
        name: 'opcWrite',
        icon: 'file:opc.png',
        group: ['transform'],
        version: 1,
        description: 'Writes data to an OPC UA server (behavior like C++ open62541 example)',
        defaults: {
            name: 'OPCWrite',
            color: '#772244',
        },
        inputs: [NodeConnectionType.Main],
        outputs: [NodeConnectionType.Main],
        properties: [
            {
                displayName: 'Host',
                name: 'host',
                type: 'string',
                default: process.env.OPC_HOST || 'opc.tcp://localhost',
                placeholder: 'opc.tcp://localhost',
                required: true,
            },
            {
                displayName: 'Port',
                name: 'port',
                type: 'number',
                default: Number(process.env.OPC_PORT) || 4840,
                required: true,
            },
            {
                displayName: 'Node ID (identifier part or full ns=..;s=..)',
                name: 'nodeId',
                type: 'string',
                default: process.env.OPC_NODE_ID || '',
                required: true,
            },
            {
                displayName: 'Route (dot-path from DATA)',
                name: 'route',
                type: 'string',
                default: 'value',
            },
            {
                displayName: 'Namespace index (used when nodeId is not full)',
                name: 'ns',
                type: 'number',
                default: Number(process.env.OPC_NS) || 2,
            },
        ],
    };

    // Single client & session stored on the instance (like C++)
    private client: OPCUAClient | null = null;
    private session: ClientSession | null = null;
    private endpointUrlCached: string | null = null;
    private connectedOnce = false; // tracks whether we already tried initial connect

    // Helper: replace ${ENV} in strings (like you asked earlier)
    private replaceEnvVars(str: string): string {
        if (!str) return str;
        return str.replace(/\$\{([^}]+)\}/g, (_m, name) => process.env[name] ?? '');
    }

    private splitRoute(route: string): string[] {
        if (!route) return [];
        if (route.startsWith('DATA.')) route = route.slice(5);
        return route.length ? route.split('.') : [];
    }

    private getValueByRoute(obj: any, steps: string[]): any {
        let cur = obj;
        for (const step of steps) {
            if (cur === null || cur === undefined) {
                throw new Error(`не удалось найти поле: '${step}' (промежуточное значение пустое)`);
            }
            if (Array.isArray(cur)) {
                const idx = Number(step);
                if (Number.isNaN(idx)) throw new Error(`не удалось обратиться к элементу массива с индексом: ${step}`);
                if (idx < 0 || idx >= cur.length) throw new Error(`Индекс вне диапазона: ${step}`);
                cur = cur[idx];
            } else {
                if (typeof cur !== 'object') throw new Error(`Поле ${step} не является объектом/массивом`);
                if (!(step in cur)) throw new Error(`не удалось найти поле: '${step}'`);
                cur = cur[step];
            }
        }
        return cur;
    }

    /**
     * Ensure client & session exist. To mimic C++ (which connects in ctor),
     * we attempt to create client & connect on first call (synchronously awaited here).
     *
     * IMPORTANT: we do NOT implement robust reconnection here (only one attempt on BadSecureChannelClosed),
     * to match the C++ behavior.
     */
    private async ensureConnectedFirstTime(endpointUrl: string): Promise<void> {
        if (this.client && this.session && this.endpointUrlCached === endpointUrl) {
            this.connectedOnce = true;
            return;
        }

        // If previously attempted and failed, we still try to create new client (like C++ does once)
        try {
            // create client and connect
            this.client = OPCUAClient.create({ endpoint_must_exist: false });
            this.endpointUrlCached = endpointUrl;
            // await immediate connect (C++ does synchronous connect in ctor)
            await this.client.connect(endpointUrl);
            this.session = await this.client.createSession();
            this.connectedOnce = true;
        } catch (err) {
            // In C++ you logged warning and carried on; do similar: keep client null but don't implement automatic retries
            this.client = null;
            this.session = null;
            this.connectedOnce = false;
            // throw to let node/user see connection error (C++ only warned; choose to reflect error)
            throw err;
        }
    }

    /**
     * Perform write, but mimic C++ behavior:
     * - Try writing once.
     * - If failure and error equals BadSecureChannelClosed (or status code indicates that),
     *   attempt the write a second time WITHOUT recreating client/session (just retry).
     * - If still fails — throw.
     */
    private async writeLikeCpp(nodeId: string, value: any, dataType: DataType): Promise<void> {
        if (!this.session) throw new Error('session not available');
        const nodesToWrite = [
            {
                nodeId,
                attributeId: AttributeIds.Value,
                value: {
                    value: {
                        dataType,
                        value,
                    },
                },
            },
        ];

        // first attempt
        try {
            const res = await (this.session as any).write(nodesToWrite);
            const status = Array.isArray(res) ? res[0] : res;
            if (status && status !== StatusCodes.Good) {
                // emulate C++: if BADSECURECHANNELCLOSED then try once more
                if (status === StatusCodes.BadSecureChannelClosed) {
                    // second attempt — without recreating client/session (like your C++ code tried write again)
                    const res2 = await (this.session as any).write(nodesToWrite);
                    const status2 = Array.isArray(res2) ? res2[0] : res2;
                    if (status2 && status2 !== StatusCodes.Good) {
                        throw new Error(`OPC UA write error after retry: ${status2.toString ? status2.toString() : status2}`);
                    }
                } else {
                    throw new Error(`OPC UA write error: ${status.toString ? status.toString() : status}`);
                }
            }
            return;
        } catch (err: any) {
            // node-opcua may throw; try to inspect error.statusCode
            // If it's the secure channel closed, mimic C++: one retry call (without reconnect).
            const sc = err && err.statusCode ? err.statusCode : null;
            if (sc === StatusCodes.BadSecureChannelClosed) {
                try {
                    const res2 = await (this.session as any).write(nodesToWrite);
                    const status2 = Array.isArray(res2) ? res2[0] : res2;
                    if (status2 && status2 !== StatusCodes.Good) {
                        throw new Error(`OPC UA write error after retry: ${status2.toString ? status2.toString() : status2}`);
                    }
                    return;
                } catch (err2) {
                    throw err2;
                }
            }
            // otherwise rethrow original error
            throw err;
        }
    }

    // execute uses any for this to avoid requiring n8n-core types
    async execute(this: any & _OPCWrite): Promise<INodeExecutionData[][]> {
        const workflowName = this.getWorkflow().name;
        const nodeInfo = this.getNode();
        const fromNodeFull = nodeInfo.type;
        const fromNode = fromNodeFull.includes('.') ? fromNodeFull.split('.').pop() : fromNodeFull;
        const idField = nodeInfo.name;

        const itemCount = this.getInputData().length;

        // read params (allow ${ENV})
        const rawHost = this.getNodeParameter('host', 0) as string;
        const rawPort = this.getNodeParameter('port', 0) as number;
        const rawNodeId = this.getNodeParameter('nodeId', 0) as string;
        const rawRoute = this.getNodeParameter('route', 0) as string;
        const ns = this.getNodeParameter('ns', 0) as number;

        const host = this.replaceEnvVars(rawHost);
        const port = rawPort;
        const nodeIdParam = this.replaceEnvVars(rawNodeId);
        const route = this.replaceEnvVars(rawRoute);

        // normalize endpoint url
        let endpointUrl = host;
        if (!/^opc.tcp:\/\//.test(endpointUrl)) endpointUrl = `opc.tcp://${endpointUrl}`;
        if (!/:\d+$/.test(endpointUrl) && port) endpointUrl = `${endpointUrl}:${port}`;

        try {
            if (!this.connectedOnce) {
                await this.ensureConnectedFirstTime(endpointUrl);
            }
        } catch (err) {
            throw new NodeOperationError(this.getNode(), `Не удалось подключиться к OPC UA: ${(err as Error).message || err}`);
        }

        const outputs: INodeExecutionData[] = [];
        const routeSteps = this.splitRoute(route);

        for (let i = 0; i < itemCount; i++) {
            const item = this.getInputData()[i];
            const dataRoot = item.json['DATA'];
            if (dataRoot === undefined) {
                throw new NodeOperationError(this.getNode(), 'Входной объект не содержит поля DATA');
            }

            let val: any;
            try {
                val = routeSteps.length ? this.getValueByRoute(dataRoot, routeSteps) : dataRoot;
            } catch (err) {
                throw new NodeOperationError(this.getNode(), (err as Error).message);
            }

            let dataType: DataType;
            let valueToWrite: any = val;
            if (typeof val === 'string') {
                dataType = DataType.String;
            } else if (typeof val === 'boolean') {
                dataType = DataType.Boolean;
            } else if (typeof val === 'number') {
                if (Number.isInteger(val)) {
                    if (val >= -32768 && val <= 32767) dataType = (DataType as any).Int16 ?? DataType.Int32;
                    else dataType = (DataType as any).Int32 ?? DataType.Int32;
                } else {
                    dataType = DataType.Double;
                }
            } else {
                throw new NodeOperationError(this.getNode(), 'тип поля не является простым. Разрешены только Number, Boolean, String.');
            }

            let nodeIdFull = nodeIdParam;
            if (!/^ns=\d+;/.test(nodeIdFull)) {
                nodeIdFull = `ns=${ns};s=${nodeIdFull}`;
            }

            try {
                if (!this.session) throw new Error('session not available');
                await this.writeLikeCpp(nodeIdFull, valueToWrite, dataType);
            } catch (err) {
                throw new NodeOperationError(this.getNode(), `OPC UA write error: ${(err as Error).message || err}`);
            }

            const outJson: any = {
                DATA: val,
                METADATA: {
                    chain: workflowName,
                    from: fromNode,
                    id: idField,
                    time: Date.now(),
                },
            };
            outputs.push({ json: outJson });
        }

        return this.prepareOutputData(outputs);
    }
}
