'use strict';

const { test } = require('node:test');
const assert = require('node:assert/strict');

const { createRegistry, CommandRegistry } = require('../../src/nodered/commandRegistry');

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,
    };
}

test('canonical topic dispatch invokes the handler with (source, msg, ctx)', async () => {
    const seen = [];
    const reg = createRegistry([{
        topic: 'set.mode',
        handler: (source, msg, ctx) => { seen.push({ source, msg, ctx }); },
    }]);
    const source = { id: 'src' };
    const ctx = { tag: 'ctx' };
    const msg = { topic: 'set.mode', payload: 'auto' };
    await reg.dispatch(msg, source, ctx);
    assert.equal(seen.length, 1);
    assert.equal(seen[0].source, source);
    assert.equal(seen[0].msg, msg);
    assert.equal(seen[0].ctx, ctx);
});

test('alias dispatch invokes handler and logs deprecation warning once', async () => {
    const logger = makeLogger();
    let count = 0;
    const reg = createRegistry([{
        topic: 'set.mode',
        aliases: ['setMode'],
        handler: () => { count += 1; },
    }], { logger });

    await reg.dispatch({ topic: 'setMode', payload: 'auto' }, {}, {});
    await reg.dispatch({ topic: 'setMode', payload: 'manual' }, {}, {});

    assert.equal(count, 2);
    const deprecationWarns = logger._calls.warn.filter((m) => m.includes('deprecated'));
    assert.equal(deprecationWarns.length, 1);
    assert.match(deprecationWarns[0], /setMode/);
    assert.match(deprecationWarns[0], /set\.mode/);
});

test('unknown topic logs warn and returns without throwing', async () => {
    const logger = makeLogger();
    const reg = createRegistry([{ topic: 'set.mode', handler: () => {} }], { logger });
    await reg.dispatch({ topic: 'no.such.topic' }, {}, {});
    assert.ok(logger._calls.warn.some((m) => m.includes('unknown topic')));
});

test('payloadSchema scalar rejects mismatched payload', async () => {
    const logger = makeLogger();
    let invoked = false;
    const reg = createRegistry([{
        topic: 'set.demand',
        payloadSchema: { type: 'number' },
        handler: () => { invoked = true; },
    }], { logger });

    await reg.dispatch({ topic: 'set.demand', payload: 'not-a-number' }, {}, {});
    assert.equal(invoked, false);
    assert.ok(logger._calls.warn.some((m) => m.includes('expected number')));
});

test('payloadSchema object properties enforce per-key typeof', async () => {
    const logger = makeLogger();
    const accepted = [];
    const reg = createRegistry([{
        topic: 'cmd.startup',
        payloadSchema: { type: 'object', properties: { name: 'string' } },
        handler: (_s, msg) => { accepted.push(msg.payload); },
    }], { logger });

    await reg.dispatch({ topic: 'cmd.startup', payload: { name: 'foo' } }, {}, {});
    await reg.dispatch({ topic: 'cmd.startup', payload: { name: 42 } }, {}, {});
    assert.deepEqual(accepted, [{ name: 'foo' }]);
    assert.ok(logger._calls.warn.some((m) => m.includes('payload.name')));
});

test('payloadSchema type any accepts any payload', async () => {
    const logger = makeLogger();
    const seen = [];
    const reg = createRegistry([{
        topic: 'data.measurement',
        payloadSchema: { type: 'any' },
        handler: (_s, msg) => { seen.push(msg.payload); },
    }], { logger });

    await reg.dispatch({ topic: 'data.measurement', payload: 1 }, {}, {});
    await reg.dispatch({ topic: 'data.measurement', payload: 'x' }, {}, {});
    await reg.dispatch({ topic: 'data.measurement', payload: { a: 1 } }, {}, {});
    await reg.dispatch({ topic: 'data.measurement', payload: null }, {}, {});
    assert.equal(seen.length, 4);
    assert.equal(logger._calls.warn.length, 0);
});

test('async handler returns a promise that resolves after the handler completes', async () => {
    let done = false;
    const reg = createRegistry([{
        topic: 'cmd.calibrate',
        handler: async () => {
            await new Promise((r) => setImmediate(r));
            done = true;
        },
    }]);
    const p = reg.dispatch({ topic: 'cmd.calibrate' }, {}, {});
    assert.equal(done, false);
    await p;
    assert.equal(done, true);
});

test('duplicate canonical topic throws at construction', () => {
    assert.throws(() => createRegistry([
        { topic: 'set.mode', handler: () => {} },
        { topic: 'set.mode', handler: () => {} },
    ]), /duplicate command topic/);
});

test('alias collides with another command canonical topic throws', () => {
    assert.throws(() => createRegistry([
        { topic: 'set.mode', handler: () => {} },
        { topic: 'cmd.startup', aliases: ['set.mode'], handler: () => {} },
    ]), /collides/);
});

test('alias collides with another alias throws', () => {
    assert.throws(() => createRegistry([
        { topic: 'set.mode',  aliases: ['mode'], handler: () => {} },
        { topic: 'cmd.start', aliases: ['mode'], handler: () => {} },
    ]), /collides/);
});

test('list() returns descriptors without handler functions', () => {
    const reg = createRegistry([
        { topic: 'set.mode', aliases: ['setMode'], payloadSchema: { type: 'string' }, handler: () => {} },
        { topic: 'cmd.startup', handler: () => {} },
    ]);
    const list = reg.list();
    assert.equal(list.length, 2);
    assert.deepEqual(list[0], {
        topic: 'set.mode',
        aliases: ['setMode'],
        payloadSchema: { type: 'string' },
        description: null,
        units: null,
    });
    assert.deepEqual(list[1], {
        topic: 'cmd.startup',
        aliases: [],
        payloadSchema: null,
        description: null,
        units: null,
    });
    for (const d of list) assert.ok(!('handler' in d), 'handler must not be in descriptor');
});

test("payloadSchema type 'none' invokes handler with no payload and no warning", async () => {
    const logger = makeLogger();
    let invoked = 0;
    const reg = createRegistry([{
        topic: 'cmd.calibrate',
        payloadSchema: { type: 'none' },
        handler: () => { invoked += 1; },
    }], { logger });

    await reg.dispatch({ topic: 'cmd.calibrate' }, {}, {});
    await reg.dispatch({ topic: 'cmd.calibrate', payload: undefined }, {}, {});
    await reg.dispatch({ topic: 'cmd.calibrate', payload: null }, {}, {});
    assert.equal(invoked, 3);
    assert.equal(logger._calls.warn.length, 0);
});

test("payloadSchema type 'none' invokes handler with non-empty payload but logs warn", async () => {
    const logger = makeLogger();
    let invoked = 0;
    const reg = createRegistry([{
        topic: 'cmd.calibrate',
        payloadSchema: { type: 'none' },
        handler: () => { invoked += 1; },
    }], { logger });

    await reg.dispatch({ topic: 'cmd.calibrate', payload: 'ignored' }, {}, {});
    await reg.dispatch({ topic: 'cmd.calibrate', payload: { a: 1 } }, {}, {});
    await reg.dispatch({ topic: 'cmd.calibrate', payload: 0 }, {}, {});
    assert.equal(invoked, 3);
    const warns = logger._calls.warn.filter((m) => m.includes('payload ignored'));
    assert.equal(warns.length, 3);
    assert.ok(warns[0].includes('cmd.calibrate'));
    assert.ok(warns[0].includes('trigger-only'));
});

test('list() includes description field when present', () => {
    const reg = createRegistry([
        { topic: 'cmd.calibrate', payloadSchema: { type: 'none' }, description: 'Trigger calibration.', handler: () => {} },
        { topic: 'set.mode', payloadSchema: { type: 'string' }, handler: () => {} },
    ]);
    const list = reg.list();
    assert.equal(list[0].description, 'Trigger calibration.');
    assert.equal(list[1].description, null);
});

test('deprecationStats reflects alias hit counts', async () => {
    const logger = makeLogger();
    const reg = createRegistry([{
        topic: 'set.mode',
        aliases: ['setMode', 'changemode'],
        handler: () => {},
    }], { logger });

    await reg.dispatch({ topic: 'setMode', payload: 'a' }, {}, {});
    await reg.dispatch({ topic: 'setMode', payload: 'b' }, {}, {});
    await reg.dispatch({ topic: 'changemode', payload: 'c' }, {}, {});
    await reg.dispatch({ topic: 'set.mode', payload: 'd' }, {}, {});

    assert.deepEqual(reg.deprecationStats(), { setMode: 2, changemode: 1 });
});

test('canonical() resolves alias to canonical topic; passes through canonical', () => {
    const reg = createRegistry([{ topic: 'set.mode', aliases: ['setMode'], handler: () => {} }]);
    assert.equal(reg.canonical('setMode'), 'set.mode');
    assert.equal(reg.canonical('set.mode'), 'set.mode');
    assert.equal(reg.canonical('unknown'), 'unknown');
});

test('has() reports membership for canonical and alias keys', () => {
    const reg = createRegistry([{ topic: 'set.mode', aliases: ['setMode'], handler: () => {} }]);
    assert.equal(reg.has('set.mode'), true);
    assert.equal(reg.has('setMode'), true);
    assert.equal(reg.has('nope'), false);
});

test('CommandRegistry class is exported for advanced cases', () => {
    const reg = new CommandRegistry([{ topic: 'set.mode', handler: () => {} }]);
    assert.ok(reg instanceof CommandRegistry);
});

test('msg without topic logs warn and does not throw', async () => {
    const logger = makeLogger();
    const reg = createRegistry([{ topic: 'set.mode', handler: () => {} }], { logger });
    await reg.dispatch({ payload: 'x' }, {}, {});
    assert.ok(logger._calls.warn.some((m) => m.includes('no topic')));
});

test('ctx.logger overrides the constructor logger at dispatch time', async () => {
    const ctorLogger = makeLogger();
    const ctxLogger = makeLogger();
    const reg = createRegistry([{ topic: 'set.mode', handler: () => {} }], { logger: ctorLogger });
    await reg.dispatch({ topic: 'unknown' }, {}, { logger: ctxLogger });
    assert.equal(ctorLogger._calls.warn.length, 0);
    assert.ok(ctxLogger._calls.warn.some((m) => m.includes('unknown topic')));
});

test('object schema rejects null payload (typeof null === object guard)', async () => {
    const logger = makeLogger();
    let invoked = false;
    const reg = createRegistry([{
        topic: 'cmd.startup',
        payloadSchema: { type: 'object' },
        handler: () => { invoked = true; },
    }], { logger });
    await reg.dispatch({ topic: 'cmd.startup', payload: null }, {}, {});
    assert.equal(invoked, false);
    assert.ok(logger._calls.warn.some((m) => m.includes('expected object')));
});

test('constructor throws on missing topic / handler', () => {
    assert.throws(() => createRegistry([{ handler: () => {} }]), /topic/);
    assert.throws(() => createRegistry([{ topic: 'set.x' }]), /handler/);
});

test('constructor throws when input is not an array', () => {
    assert.throws(() => createRegistry(null), /array/);
    assert.throws(() => createRegistry({}), /array/);
});

// ---------------------------------------------------------------------------
// descriptor.units — Phase 11 pre-dispatch normalisation pipeline
// ---------------------------------------------------------------------------

test('units: valid unit + correct measure converts to default before handler', async () => {
    const logger = makeLogger();
    const seen = [];
    const reg = createRegistry([{
        topic: 'set.demand',
        units: { measure: 'volumeFlowRate', default: 'm3/h' },
        handler: (_s, msg) => { seen.push({ payload: msg.payload, unit: msg.unit }); },
    }], { logger });

    await reg.dispatch({ topic: 'set.demand', payload: 1, unit: 'm3/s' }, {}, {});
    assert.equal(seen.length, 1);
    assert.ok(Math.abs(seen[0].payload - 3600) < 1e-6, `expected 3600, got ${seen[0].payload}`);
    assert.equal(seen[0].unit, 'm3/h');
    assert.equal(logger._calls.warn.length, 0);
});

test('units: wrong measure warns + lists accepted + falls back to default unit', async () => {
    const logger = makeLogger();
    const seen = [];
    const reg = createRegistry([{
        topic: 'set.demand',
        units: { measure: 'volumeFlowRate', default: 'm3/h' },
        handler: (_s, msg) => { seen.push({ payload: msg.payload, unit: msg.unit }); },
    }], { logger });

    await reg.dispatch({ topic: 'set.demand', payload: 42, unit: 'mbar' }, {}, {});
    assert.equal(seen.length, 1);
    assert.equal(seen[0].payload, 42);
    assert.equal(seen[0].unit, 'm3/h');
    const warns = logger._calls.warn;
    assert.equal(warns.length, 1);
    assert.match(warns[0], /set\.demand/);
    assert.match(warns[0], /'mbar'/);
    assert.match(warns[0], /pressure/);
    assert.match(warns[0], /volumeFlowRate/);
    assert.match(warns[0], /m3\/h/);              // accepted list contains the default
    assert.match(warns[0], /Treating 42 as m3\/h/);
});

test('units: unknown unit warns + lists accepted + falls back to default', async () => {
    const logger = makeLogger();
    const seen = [];
    const reg = createRegistry([{
        topic: 'set.demand',
        units: { measure: 'volumeFlowRate', default: 'm3/h' },
        handler: (_s, msg) => { seen.push({ payload: msg.payload, unit: msg.unit }); },
    }], { logger });

    await reg.dispatch({ topic: 'set.demand', payload: 7, unit: 'flarbargs' }, {}, {});
    assert.equal(seen.length, 1);
    assert.equal(seen[0].payload, 7);
    assert.equal(seen[0].unit, 'm3/h');
    const warns = logger._calls.warn;
    assert.equal(warns.length, 1);
    assert.match(warns[0], /unknown unit 'flarbargs'/);
    assert.match(warns[0], /m3\/h/);
    assert.match(warns[0], /Treating 7 as m3\/h/);
});

test('units: no unit at all — handler gets raw value tagged with default unit, silent', async () => {
    const logger = makeLogger();
    const seen = [];
    const reg = createRegistry([{
        topic: 'set.demand',
        units: { measure: 'volumeFlowRate', default: 'm3/h' },
        handler: (_s, msg) => { seen.push({ payload: msg.payload, unit: msg.unit }); },
    }], { logger });

    await reg.dispatch({ topic: 'set.demand', payload: 12 }, {}, {});
    assert.equal(seen.length, 1);
    assert.equal(seen[0].payload, 12);
    assert.equal(seen[0].unit, 'm3/h');
    assert.equal(logger._calls.warn.length, 0);
});

test('units: object payload {value, unit} normalises the same as msg.payload+msg.unit', async () => {
    const logger = makeLogger();
    const seen = [];
    const reg = createRegistry([{
        topic: 'set.pressure',
        units: { measure: 'pressure', default: 'Pa' },
        handler: (_s, msg) => { seen.push({ payload: msg.payload, unit: msg.unit }); },
    }], { logger });

    await reg.dispatch({ topic: 'set.pressure', payload: { value: 5, unit: 'mbar' } }, {}, {});
    assert.equal(seen.length, 1);
    assert.ok(Math.abs(seen[0].payload - 500) < 1e-6, `expected 500, got ${seen[0].payload}`);
    assert.equal(seen[0].unit, 'Pa');
    assert.equal(logger._calls.warn.length, 0);
});

test('units: object payload {value} without unit falls back to default unit silently', async () => {
    const logger = makeLogger();
    const seen = [];
    const reg = createRegistry([{
        topic: 'set.pressure',
        units: { measure: 'pressure', default: 'Pa' },
        handler: (_s, msg) => { seen.push({ payload: msg.payload, unit: msg.unit }); },
    }], { logger });

    await reg.dispatch({ topic: 'set.pressure', payload: { value: 100 } }, {}, {});
    assert.equal(seen.length, 1);
    assert.equal(seen[0].payload, 100);
    assert.equal(seen[0].unit, 'Pa');
    assert.equal(logger._calls.warn.length, 0);
});

test('units: non-NUMERIC string payload (not normalisable) passes through to handler', async () => {
    const logger = makeLogger();
    const seen = [];
    const reg = createRegistry([{
        topic: 'set.demand',
        unit: 'm3/h',
        handler: (_s, msg) => { seen.push(msg.payload); },
    }], { logger });

    // non-numeric string — not normalisable. Should not crash; handler still fires.
    await reg.dispatch({ topic: 'set.demand', payload: 'magic' }, {}, {});
    assert.equal(seen.length, 1);
    assert.equal(seen[0], 'magic');
});

test('units: NUMERIC string payload is always converted (closes the string gap)', async () => {
    const logger = makeLogger();
    const seen = [];
    const reg = createRegistry([{
        topic: 'set.demand',
        unit: 'm3/h',
        handler: (_s, msg) => { seen.push({ payload: msg.payload, unit: msg.unit }); },
    }], { logger });

    // "1" m3/s must convert to 3600 m3/h — same as the numeric-payload case.
    await reg.dispatch({ topic: 'set.demand', payload: '1', unit: 'm3/s' }, {}, {});
    assert.equal(seen.length, 1);
    assert.ok(Math.abs(seen[0].payload - 3600) < 1e-6, `expected 3600, got ${seen[0].payload}`);
    assert.equal(seen[0].unit, 'm3/h');
    assert.equal(logger._calls.warn.length, 0);
});

test('units: { value: numeric-string } object payload is converted too', async () => {
    const seen = [];
    const reg = createRegistry([{
        topic: 'set.pressure',
        unit: 'Pa',
        handler: (_s, msg) => { seen.push({ payload: msg.payload, unit: msg.unit }); },
    }]);
    await reg.dispatch({ topic: 'set.pressure', payload: { value: '5', unit: 'mbar' } }, {}, {});
    assert.ok(Math.abs(seen[0].payload - 500) < 1e-6, `expected 500, got ${seen[0].payload}`);
    assert.equal(seen[0].unit, 'Pa');
});

test('unit: shorthand declares the unit and DERIVES the measure', async () => {
    const seen = [];
    const reg = createRegistry([{
        topic: 'set.demand',
        unit: 'm3/h',
        handler: (_s, msg) => { seen.push({ payload: msg.payload, unit: msg.unit }); },
    }]);
    await reg.dispatch({ topic: 'set.demand', payload: 1, unit: 'm3/s' }, {}, {});
    assert.ok(Math.abs(seen[0].payload - 3600) < 1e-6);
    assert.equal(seen[0].unit, 'm3/h');
});

test('units: { default } without measure derives the measure (measure no longer required)', () => {
    const reg = createRegistry([{
        topic: 'set.demand',
        units: { default: 'm3/h' },
        handler: () => {},
    }]);
    assert.deepEqual(reg.list()[0].units, { measure: 'volumeFlowRate', default: 'm3/h' });
});

test('units: legacy { measure, default } still works; measure is re-derived from the unit', () => {
    const reg = createRegistry([{
        topic: 'set.demand',
        units: { measure: 'totallyWrong', default: 'm3/h' },
        handler: () => {},
    }]);
    // declared measure ignored — derived from the unit so it can never drift.
    assert.deepEqual(reg.list()[0].units, { measure: 'volumeFlowRate', default: 'm3/h' });
});

test('units: missing unit/default throws at construction', () => {
    assert.throws(() => createRegistry([{
        topic: 'set.demand',
        units: { measure: 'volumeFlowRate' },
        handler: () => {},
    }]), /requires a unit string/);
});

test('units: unrecognised declared unit throws at construction (descriptor bug caught early)', () => {
    assert.throws(() => createRegistry([{
        topic: 'set.demand',
        unit: 'flarbargs',
        handler: () => {},
    }]), /convert does not recognise/);
});

test('units: descriptor.units surfaces in list() output', () => {
    const reg = createRegistry([
        { topic: 'set.demand', unit: 'm3/h', handler: () => {} },
        { topic: 'set.mode',                 handler: () => {} },
    ]);
    const list = reg.list();
    assert.deepEqual(list[0].units, { measure: 'volumeFlowRate', default: 'm3/h' });
    assert.equal(list[1].units, null);
});

// ---------------------------------------------------------------------------
// origin — command provenance + mode-gated control-authority arbitration
// ---------------------------------------------------------------------------

test('origin: defaults to parent and is stamped on msg before the handler runs', async () => {
    const seen = [];
    const reg = createRegistry([{ topic: 'set.mode', handler: (_s, msg) => { seen.push(msg.origin); } }]);
    await reg.dispatch({ topic: 'set.mode', payload: 'auto' }, {}, {});
    assert.equal(seen[0], 'parent');
});

test('origin: explicit msg.origin is preserved and trimmed', async () => {
    const seen = [];
    const reg = createRegistry([{ topic: 'set.mode', handler: (_s, msg) => { seen.push(msg.origin); } }]);
    await reg.dispatch({ topic: 'set.mode', payload: 'auto', origin: ' GUI ' }, {}, {});
    assert.equal(seen[0], 'GUI');
});

test('origin: defaultOrigin descriptor overrides the global parent default', async () => {
    const seen = [];
    const reg = createRegistry([{ topic: 'set.x', defaultOrigin: 'fysical', handler: (_s, msg) => { seen.push(msg.origin); } }]);
    await reg.dispatch({ topic: 'set.x' }, {}, {});
    assert.equal(seen[0], 'fysical');
});

test('origin gating: non-gated command is never blocked, even with a mode model', async () => {
    let invoked = false;
    const reg = createRegistry([{ topic: 'set.x', handler: () => { invoked = true; } }]);
    const source = { currentMode: 'fysicalControl', config: { mode: { allowedSources: { fysicalControl: ['fysical'] } } } };
    await reg.dispatch({ topic: 'set.x', origin: 'GUI' }, source, {});
    assert.equal(invoked, true);
});

test('origin gating: gated command rejects an origin disallowed by the current mode', async () => {
    const logger = makeLogger();
    let invoked = false;
    const reg = createRegistry([{ topic: 'set.x', gated: true, handler: () => { invoked = true; } }], { logger });
    const source = { currentMode: 'fysicalControl', config: { mode: { allowedSources: { fysicalControl: ['fysical'] } } } };
    await reg.dispatch({ topic: 'set.x', origin: 'GUI' }, source, {});
    assert.equal(invoked, false);
    assert.ok(logger._calls.warn.some((m) => m.includes("origin 'GUI' not allowed in mode 'fysicalControl'")));
});

test('origin gating: gated command accepts an allowed origin (Set or array allowedSources)', async () => {
    let count = 0;
    const reg = createRegistry([{ topic: 'set.x', gated: true, handler: () => { count += 1; } }]);
    const arraySrc = { currentMode: 'auto', config: { mode: { allowedSources: { auto: ['parent', 'GUI', 'fysical'] } } } };
    const setSrc   = { currentMode: 'auto', config: { mode: { allowedSources: { auto: new Set(['parent', 'GUI', 'fysical']) } } } };
    await reg.dispatch({ topic: 'set.x', origin: 'GUI' }, arraySrc, {});
    await reg.dispatch({ topic: 'set.x', origin: 'GUI' }, setSrc, {});
    assert.equal(count, 2);
});

test('origin gating: gated command on a node WITHOUT a mode model is advisory (allow-all)', async () => {
    let invoked = false;
    const reg = createRegistry([{ topic: 'set.x', gated: true, handler: () => { invoked = true; } }]);
    await reg.dispatch({ topic: 'set.x', origin: 'GUI' }, { id: 'no-mode' }, {});
    assert.equal(invoked, true);
});
