'use strict';

const test = require('node:test');
const assert = require('node:assert/strict');
const EventEmitter = require('events');

const BaseNodeAdapter = require('../../src/nodered/BaseNodeAdapter');

// ---- test doubles ---------------------------------------------------------

function makeLogger() {
    const calls = { warn: [], error: [], info: [], debug: [] };
    return {
        warn:  (...a) => calls.warn.push(a.join(' ')),
        error: (...a) => calls.error.push(a.join(' ')),
        info:  (...a) => calls.info.push(a.join(' ')),
        debug: (...a) => calls.debug.push(a.join(' ')),
        _calls: calls,
    };
}

function makeNode(id = 'node-1') {
    const sends = [];
    const statuses = [];
    const handlers = {};
    return {
        id,
        sends,
        statuses,
        handlers,
        send(arr)   { sends.push(arr); },
        status(b)   { statuses.push(b); },
        on(ev, fn)  { handlers[ev] = fn; },
        warn()      {},
        error()     {},
    };
}

function makeRED() {
    return { nodes: { getNode: () => null } };
}

// Fake domain — surfaces just enough of the BaseDomain contract that
// BaseNodeAdapter touches (config, logger, emitter, getOutput, getStatusBadge,
// optionally tick + close). Avoids the JSON-config dependency BaseDomain has.
function makeDomain(opts = {}) {
    const logger = opts.logger || makeLogger();
    return class FakeDomain {
        constructor(config) {
            this.config = config;
            this.logger = logger;
            this.emitter = new EventEmitter();
            this.tickCount = 0;
            this.closed = false;
            this._output = opts.output || { temperature: 21 };
            this._badge = opts.badge || { fill: 'green', shape: 'dot', text: 'OK' };
        }
        tick() { this.tickCount += 1; }
        getOutput() { return this._output; }
        getStatusBadge() { return this._badge; }
        close() { this.closed = true; }
    };
}

// uiConfig field set used by configManager.buildConfig — measurement is
// chosen as the config-file name because measurement.json ships in
// generalFunctions/src/configs and getConfig() is called during construction.
function uiConfigFixture() {
    return {
        name: 'm1', unit: 'C', logLevel: 'warn',
        positionVsParent: 'upstream', hasDistance: true, distance: 5,
    };
}

// ---- 1. Construction with full subclass succeeds --------------------------

test('full subclass constructs and stores wiring on this', () => {
    const Domain = makeDomain();
    class Adapter extends BaseNodeAdapter {
        static DomainClass = Domain;
        static commands = [];
        // Disable the real status interval — would hold the event loop open
        // past the test and stall `node --test test/basic/` runs.
        static statusInterval = 0;
        buildDomainConfig() { return { extra: { foo: 1 } }; }
    }
    const node = makeNode();
    const a = new Adapter(uiConfigFixture(), makeRED(), node, 'measurement');
    assert.equal(a.name, 'measurement');
    assert.equal(a.node, node);
    assert.equal(node.source, a.source);
    assert.equal(a.config.extra.foo, 1);
    assert.equal(a.config.general.name, 'm1');
    node.handlers.close(() => {});
});

// ---- 2-4. Static-field validation -----------------------------------------

test('direct new BaseNodeAdapter() throws abstract error', () => {
    assert.throws(
        () => new BaseNodeAdapter({}, makeRED(), makeNode(), 'measurement'),
        /abstract/,
    );
});

test('subclass without static DomainClass throws clearly', () => {
    class Bad extends BaseNodeAdapter { static commands = []; buildDomainConfig() { return {}; } }
    assert.throws(
        () => new Bad({}, makeRED(), makeNode(), 'measurement'),
        /DomainClass is required/,
    );
});

test('subclass without static commands throws clearly', () => {
    class Bad extends BaseNodeAdapter {
        static DomainClass = makeDomain();
        buildDomainConfig() { return {}; }
    }
    assert.throws(
        () => new Bad({}, makeRED(), makeNode(), 'measurement'),
        /commands is required/,
    );
});

test('static commands = [] is allowed (explicit no-op registry)', () => {
    class Adapter extends BaseNodeAdapter {
        static DomainClass = makeDomain();
        static commands = [];
        static statusInterval = 0; // see fix in test #1
        buildDomainConfig() { return {}; }
    }
    const node = makeNode();
    assert.doesNotThrow(
        () => new Adapter(uiConfigFixture(), makeRED(), node, 'measurement'),
    );
    node.handlers.close(() => {});
});

// ---- 5. Registration message after 100 ms ---------------------------------

test('registration message fires on Port 2 after 100 ms with child.register', (t) => {
    t.mock.timers.enable({ apis: ['setTimeout', 'setInterval'] });
    class Adapter extends BaseNodeAdapter {
        static DomainClass = makeDomain();
        static commands = [];
        buildDomainConfig() { return {}; }
    }
    const node = makeNode('xyz');
    new Adapter(uiConfigFixture(), makeRED(), node, 'measurement');
    assert.equal(node.sends.length, 0);
    t.mock.timers.tick(100);
    assert.equal(node.sends.length, 1);
    const [p0, p1, reg] = node.sends[0];
    assert.equal(p0, null);
    assert.equal(p1, null);
    assert.equal(reg.topic, 'child.register');
    assert.equal(reg.payload, 'xyz');
    assert.equal(reg.positionVsParent, 'upstream');
    assert.equal(reg.distance, 5);
});

// ---- 6. Tick mode ---------------------------------------------------------

test('static tickInterval > 0 calls source.tick() on schedule and emits outputs', (t) => {
    t.mock.timers.enable({ apis: ['setInterval', 'setTimeout'] });
    class Adapter extends BaseNodeAdapter {
        static DomainClass = makeDomain();
        static commands = [];
        static tickInterval = 50;
        static statusInterval = 0;
        buildDomainConfig() { return {}; }
    }
    const node = makeNode();
    const a = new Adapter(uiConfigFixture(), makeRED(), node, 'measurement');
    assert.equal(a.source.tickCount, 0);
    t.mock.timers.tick(50);
    assert.equal(a.source.tickCount, 1);
    t.mock.timers.tick(100);
    assert.equal(a.source.tickCount, 3);
    // Every tick triggers an output emission (the first carries the changed
    // fields; subsequent ones may emit nulls because of delta compression —
    // but node.send is called either way).
    assert.ok(node.sends.length >= 3);
});

// ---- 7. Event-driven default ----------------------------------------------

test('default (no tick) subscribes to "output-changed" on source.emitter', (t) => {
    t.mock.timers.enable({ apis: ['setTimeout'] });
    class Adapter extends BaseNodeAdapter {
        static DomainClass = makeDomain();
        static commands = [];
        static statusInterval = 0;
        buildDomainConfig() { return {}; }
    }
    const node = makeNode();
    const a = new Adapter(uiConfigFixture(), makeRED(), node, 'measurement');
    // Drain the registration tick so we can isolate output emissions.
    t.mock.timers.tick(100);
    const before = node.sends.length;
    a.source.emitter.emit('output-changed');
    assert.equal(node.sends.length, before + 1);
    const last = node.sends[node.sends.length - 1];
    assert.equal(last.length, 3);
    assert.equal(last[2], null);
});

// ---- 8. _emitOutputs shape ------------------------------------------------

test('_emitOutputs sends [processMsg, influxMsg, null] with both formatters', () => {
    class Adapter extends BaseNodeAdapter {
        static DomainClass = makeDomain({ output: { v: 1 } });
        static commands = [];
        static statusInterval = 0;
        buildDomainConfig() { return {}; }
    }
    const node = makeNode();
    const a = new Adapter(uiConfigFixture(), makeRED(), node, 'measurement');
    node.sends.length = 0;
    a._emitOutputs();
    assert.equal(node.sends.length, 1);
    const [proc, influx, port2] = node.sends[0];
    assert.ok(proc && typeof proc === 'object', 'process msg present');
    assert.ok(influx && typeof influx === 'object', 'influxdb msg present');
    assert.equal(port2, null);
});

// ---- 9-10. Input dispatch -------------------------------------------------

test('input handler dispatches a known topic to the registered handler', async () => {
    const seen = [];
    class Adapter extends BaseNodeAdapter {
        static DomainClass = makeDomain();
        static commands = [{
            topic: 'set.mode',
            handler: (source, msg, ctx) => { seen.push({ source, msg, ctx }); },
        }];
        static statusInterval = 0;
        buildDomainConfig() { return {}; }
    }
    const node = makeNode();
    const a = new Adapter(uiConfigFixture(), makeRED(), node, 'measurement');
    let donec = 0;
    await node.handlers.input({ topic: 'set.mode', payload: 'auto' }, () => {}, () => { donec += 1; });
    assert.equal(seen.length, 1);
    assert.equal(seen[0].source, a.source);
    assert.equal(seen[0].msg.payload, 'auto');
    assert.equal(donec, 1);
});

test('input handler with unknown topic warns and does not crash', async () => {
    const logger = makeLogger();
    class Adapter extends BaseNodeAdapter {
        static DomainClass = makeDomain({ logger });
        static commands = [];
        static statusInterval = 0;
        buildDomainConfig() { return {}; }
    }
    const node = makeNode();
    new Adapter(uiConfigFixture(), makeRED(), node, 'measurement');
    let donec = 0;
    await node.handlers.input({ topic: 'totally.unknown', payload: 1 }, () => {}, () => { donec += 1; });
    assert.equal(donec, 1);
    assert.ok(logger._calls.warn.some((m) => m.includes('totally.unknown')));
});

// ---- 11. Status updater wiring --------------------------------------------

test('status updater receives static statusInterval', (t) => {
    t.mock.timers.enable({ apis: ['setInterval', 'setTimeout'] });
    class Adapter extends BaseNodeAdapter {
        static DomainClass = makeDomain({ badge: { fill: 'red', shape: 'ring', text: 'X' } });
        static commands = [];
        static statusInterval = 250;
        buildDomainConfig() { return {}; }
    }
    const node = makeNode();
    new Adapter(uiConfigFixture(), makeRED(), node, 'measurement');
    assert.equal(node.statuses.length, 0);
    t.mock.timers.tick(250);
    assert.equal(node.statuses.length, 1);
    assert.deepEqual(node.statuses[0], { fill: 'red', shape: 'ring', text: 'X' });
});

// ---- 12. Close handler ----------------------------------------------------

test('close handler clears tick interval, stops status, clears badge, calls source.close', (t) => {
    t.mock.timers.enable({ apis: ['setInterval', 'setTimeout'] });
    class Adapter extends BaseNodeAdapter {
        static DomainClass = makeDomain();
        static commands = [];
        static tickInterval = 100;
        static statusInterval = 100;
        buildDomainConfig() { return {}; }
    }
    const node = makeNode();
    const a = new Adapter(uiConfigFixture(), makeRED(), node, 'measurement');
    t.mock.timers.tick(200); // two ticks fire
    const ticksAtClose = a.source.tickCount;
    let donec = 0;
    node.handlers.close(() => { donec += 1; });
    assert.equal(donec, 1);
    assert.equal(a.source.closed, true);
    // Final node.status({}) appears in statuses.
    assert.deepEqual(node.statuses[node.statuses.length - 1], {});
    // No further ticks after close.
    t.mock.timers.tick(1000);
    assert.equal(a.source.tickCount, ticksAtClose);
});

// ---- 13. Hook points fire when defined ------------------------------------

// ---- 14-16. Auto-wired query.units ---------------------------------------

test('implicit query.units returns measure+default+accepted for every units-declaring topic', async () => {
    class Adapter extends BaseNodeAdapter {
        static DomainClass = makeDomain();
        static commands = [
            {
                topic: 'set.demand',
                units: { measure: 'volumeFlowRate', default: 'm3/h' },
                payloadSchema: { type: 'number' },
                handler: () => {},
            },
            {
                topic: 'cmd.calibrate.volume',
                units: { measure: 'volume', default: 'm3' },
                payloadSchema: { type: 'number' },
                handler: () => {},
            },
            {
                topic: 'set.mode',
                payloadSchema: { type: 'string' },
                handler: () => {},
            },
        ];
        static statusInterval = 0;
        buildDomainConfig() { return {}; }
    }
    const node = makeNode();
    new Adapter(uiConfigFixture(), makeRED(), node, 'measurement');
    const sent = [];
    await node.handlers.input(
        { topic: 'query.units' },
        (arr) => sent.push(arr),
        () => {},
    );
    assert.equal(sent.length, 1);
    const [p0, p1, p2] = sent[0];
    assert.equal(p1, null);
    assert.equal(p2, null);
    assert.equal(p0.topic, 'query.units');
    assert.equal(p0.payload.node, 'measurement');
    const u = p0.payload.units;
    assert.ok(u['set.demand'], 'set.demand entry present');
    assert.equal(u['set.demand'].measure, 'volumeFlowRate');
    assert.equal(u['set.demand'].default, 'm3/h');
    assert.ok(Array.isArray(u['set.demand'].accepted), 'accepted is an array');
    assert.ok(u['set.demand'].accepted.length > 0, 'accepted is non-empty');
    assert.ok(u['cmd.calibrate.volume'], 'cmd.calibrate.volume entry present');
    assert.equal(u['cmd.calibrate.volume'].measure, 'volume');
    assert.equal(u['cmd.calibrate.volume'].default, 'm3');
    // Topic without units does not show up.
    assert.equal(u['set.mode'], undefined);
    node.handlers.close(() => {});
});

test('implicit query.units returns empty units object when no command declares units', async () => {
    class Adapter extends BaseNodeAdapter {
        static DomainClass = makeDomain();
        static commands = [
            { topic: 'set.mode', payloadSchema: { type: 'string' }, handler: () => {} },
        ];
        static statusInterval = 0;
        buildDomainConfig() { return {}; }
    }
    const node = makeNode();
    new Adapter(uiConfigFixture(), makeRED(), node, 'measurement');
    const sent = [];
    await node.handlers.input(
        { topic: 'query.units' },
        (arr) => sent.push(arr),
        () => {},
    );
    assert.equal(sent.length, 1);
    const [p0] = sent[0];
    assert.equal(p0.topic, 'query.units');
    assert.deepEqual(p0.payload.units, {});
    assert.equal(p0.payload.node, 'measurement');
    node.handlers.close(() => {});
});

test('explicit query.units descriptor wins over the implicit auto-wired handler', async () => {
    let customRan = 0;
    class Adapter extends BaseNodeAdapter {
        static DomainClass = makeDomain();
        static commands = [
            {
                topic: 'set.demand',
                units: { measure: 'volumeFlowRate', default: 'm3/h' },
                payloadSchema: { type: 'number' },
                handler: () => {},
            },
            {
                topic: 'query.units',
                payloadSchema: { type: 'any' },
                handler: (source, msg, ctx) => {
                    customRan += 1;
                    if (ctx && typeof ctx.send === 'function') {
                        ctx.send([{ topic: 'query.units', payload: 'CUSTOM' }, null, null]);
                    }
                },
            },
        ];
        static statusInterval = 0;
        buildDomainConfig() { return {}; }
    }
    const node = makeNode();
    new Adapter(uiConfigFixture(), makeRED(), node, 'measurement');
    const sent = [];
    await node.handlers.input(
        { topic: 'query.units' },
        (arr) => sent.push(arr),
        () => {},
    );
    assert.equal(customRan, 1, 'custom handler must have been called once');
    assert.equal(sent.length, 1);
    assert.equal(sent[0][0].payload, 'CUSTOM',
        'reply payload comes from the subclass-declared handler, not the implicit one');
    node.handlers.close(() => {});
});

test('extraSetup, extraInputDispatch, extraClose hooks fire when present', async (t) => {
    t.mock.timers.enable({ apis: ['setTimeout'] });
    const trace = [];
    class Adapter extends BaseNodeAdapter {
        static DomainClass = makeDomain();
        static commands = [{ topic: 'set.x', handler: () => { trace.push('handler'); } }];
        static statusInterval = 0;
        buildDomainConfig() { return {}; }
        extraSetup() { trace.push('extraSetup'); }
        extraInputDispatch(msg) { trace.push(`extraInput:${msg.topic}`); }
        extraClose() { trace.push('extraClose'); }
    }
    const node = makeNode();
    new Adapter(uiConfigFixture(), makeRED(), node, 'measurement');
    assert.ok(trace.includes('extraSetup'));
    await node.handlers.input({ topic: 'set.x', payload: 1 }, () => {}, () => {});
    assert.ok(trace.includes('handler'));
    assert.ok(trace.includes('extraInput:set.x'));
    // Unknown-topic path also runs extraInputDispatch — by design, it's the
    // fallback the contract documents.
    await node.handlers.input({ topic: 'unknown', payload: 1 }, () => {}, () => {});
    assert.ok(trace.includes('extraInput:unknown'));
    node.handlers.close(() => {});
    assert.ok(trace.includes('extraClose'));
});
