import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';

const customElementDefinitions = [];
globalThis.customElements = {
    define(name) { customElementDefinitions.push(name); },
    whenDefined() { return Promise.resolve(); },
};
globalThis.HTMLElement = class {};
globalThis.Event = class {
    constructor(type, options = {}) {
        this.type = type;
        this.bubbles = options.bubbles ?? false;
    }
};

class FakeInput {
    constructor(value = '') {
        this.value = value;
        this.events = [];
    }

    dispatchEvent(event) {
        this.events.push(event.type);
    }
}
globalThis.HTMLInputElement = FakeInput;
const inputs = new Map([
    ['form_one_payload', new FakeInput('obsolete-one')],
    ['form_one_honeypot', new FakeInput('uninitialized')],
    ['form_two_payload', new FakeInput('obsolete-two')],
    ['form_two_honeypot', new FakeInput('uninitialized')],
    ['form_bot_payload', new FakeInput('')],
    ['form_bot_honeypot', new FakeInput('uninitialized')],
    ['form_missing_payload', new FakeInput('')],
    ['form_missing_honeypot', new FakeInput('uninitialized')],
    ['registration_payload', new FakeInput('')],
    ['registration_honeypot', new FakeInput('uninitialized')],
    ['live_action_payload', new FakeInput('')],
    ['live_action_honeypot', new FakeInput('uninitialized')],
    ['live_retry_payload', new FakeInput('')],
    ['live_retry_honeypot', new FakeInput('uninitialized')],
]);
globalThis.document = {
    documentElement: { lang: 'fr-FR' },
    getElementById(id) { return inputs.get(id) ?? null; },
    createElement() { return {}; },
    querySelector() { return null; },
    head: { appendChild() {} },
};

const {
    createRetryableLoader,
    default: AltchaController,
} = await import('../../assets/controllers/altcha_controller.js');
const prototype = AltchaController.prototype;
assert.deepEqual(
    customElementDefinitions,
    [],
    'ALTCHA must be imported after the parent LiveComponent has initialized its loading plugin.',
);

let loadAttempts = 0;
const retryableLoader = createRetryableLoader(async () => {
    ++loadAttempts;
    if (loadAttempts === 1) {
        throw new Error('temporary chunk failure');
    }
});
await assert.rejects(retryableLoader(), /temporary chunk failure/);
await retryableLoader();
await retryableLoader();
assert.equal(loadAttempts, 2, 'A rejected chunk load must be retried and a successful one cached.');

function listenerKey(type, options = {}) {
    return `${type}:${options === true || options?.capture === true}`;
}

function createForm() {
    const listeners = new Map();
    return {
        listeners,
        registrations: [],
        addEventListener(type, listener, options = {}) {
            listeners.set(listenerKey(type, options), listener);
            this.registrations.push(['add', type, options?.capture === true]);
        },
        removeEventListener(type, listener, options = {}) {
            const key = listenerKey(type, options);
            if (listeners.get(key) === listener) {
                listeners.delete(key);
            }
            this.registrations.push(['remove', type, options?.capture === true]);
        },
        emit(type, options = { capture: true }) {
            const event = {
                type,
                target: this,
                prevented: false,
                propagationStopped: false,
                preventDefault() { this.prevented = true; },
                stopImmediatePropagation() { this.propagationStopped = true; },
            };
            listeners.get(listenerKey(type, options))?.(event);
            return event;
        },
    };
}

function createWidget() {
    const listeners = new Map();
    const loading = {
        value: 'true',
        getAttribute() { return this.value; },
        setAttribute(name, value) { if (name === 'data-loading') this.value = value; },
    };
    return {
        listeners,
        loading,
        isConnected: true,
        dataset: { cpcreationAltchaChallengeUrl: '/_altcha/challenge/site.contact?formToken=opaque' },
        resetCalls: 0,
        addEventListener(type, listener) { listeners.set(type, listener); },
        removeEventListener(type) { listeners.delete(type); },
        querySelectorAll(selector) { return selector === '[data-loading]' ? [loading] : []; },
        reset() { ++this.resetCalls; },
    };
}

function createLiveComponent() {
    const hooks = new Map();

    return {
        hooks,
        on(name, callback) {
            const callbacks = hooks.get(name) ?? [];
            callbacks.push(callback);
            hooks.set(name, callbacks);
        },
        off(name, callback) {
            hooks.set(name, (hooks.get(name) ?? []).filter((candidate) => candidate !== callback));
        },
        emit(name, ...args) {
            for (const callback of hooks.get(name) ?? []) {
                callback(...args);
            }
        },
    };
}

function createController(
    inputId,
    honeypotInputId,
    {
        hasHoneypot = true,
        finalLiveActions = [],
        challengeUrl = '/_altcha/challenge/site.contact?formToken=opaque',
    } = {},
) {
    const form = createForm();
    const widget = createWidget();
    widget.dataset.cpcreationAltchaChallengeUrl = challengeUrl;
    const honeypot = new FakeInput('');
    const liveComponent = createLiveComponent();
    const liveRoot = { __component: liveComponent };
    const attributes = new Map();
    const element = {
        closest(selector) {
            if (selector === 'form') return form;
            if (selector === '[data-controller~="live"]') return liveRoot;
            return null;
        },
        setAttribute(name, value) { attributes.set(name, value); },
    };
    const controller = {
        element,
        hasInputIdValue: true,
        inputIdValue: inputId,
        hasHoneypotInputIdValue: true,
        honeypotInputIdValue: honeypotInputId,
        hasFinalLiveActionsValue: true,
        finalLiveActionsValue: finalLiveActions,
        hasHoneypotTarget: hasHoneypot,
        honeypotTarget: honeypot,
        hasWidgetTarget: true,
        widgetTarget: widget,
        configureCalls: 0,
        configuredChallenges: [],
        configureWidget() {
            ++this.configureCalls;
            this.configuredChallenges.push(this.widgetTarget.dataset.cpcreationAltchaChallengeUrl);
        },
        attachLiveComponentHooks() { return prototype.attachLiveComponentHooks.call(this); },
        detachLiveComponentHooks() { return prototype.detachLiveComponentHooks.call(this); },
        handleLiveRequestStarted(requestConfig) {
            return prototype.handleLiveRequestStarted.call(this, requestConfig);
        },
        handleLiveResponse() { return prototype.handleLiveResponse.call(this); },
        sanitizeWidgetLoadingAttributes() { return prototype.sanitizeWidgetLoadingAttributes.call(this); },
        syncHoneypot() { return prototype.syncHoneypot.call(this); },
        writeHoneypotMirror(value) { return prototype.writeHoneypotMirror.call(this, value); },
        writePayload(payload) { return prototype.writePayload.call(this, payload); },
        handleVerified(event) { return prototype.handleVerified.call(this, event); },
        handleState(event) { return prototype.handleState.call(this, event); },
        reset(event) { return prototype.reset.call(this, event); },
        prepareSubmission(event) { return prototype.prepareSubmission.call(this, event); },
        targetsThisForm(event) { return prototype.targetsThisForm.call(this, event); },
    };

    prototype.connect.call(controller);

    return { attributes, controller, element, form, honeypot, liveComponent, widget };
}

const registration = createController('registration_payload', 'registration_honeypot');
registration.widget.listeners.get('verified')({ detail: { payload: 'registration-proof' } });
registration.liveComponent.emit('request:started', {});
registration.liveComponent.emit('render:finished', registration.liveComponent);
assert.equal(
    inputs.get('registration_payload').value,
    'registration-proof',
    'A LiveComponent render caused by the verified payload must preserve the proof.',
);
assert.equal(
    registration.widget.resetCalls,
    0,
    'A LiveComponent render caused by the verified payload must keep the widget checked.',
);
prototype.disconnect.call(registration.controller);

const liveAction = createController('live_action_payload', 'live_action_honeypot', {
    finalLiveActions: ['submitRegisterConfirm'],
});
liveAction.widget.listeners.get('verified')({ detail: { payload: 'live-action-proof' } });

liveAction.liveComponent.emit('request:started', { actions: [] });
liveAction.liveComponent.emit('render:finished', liveAction.liveComponent);
assert.equal(
    inputs.get('live_action_payload').value,
    'live-action-proof',
    'A passive LiveComponent render must preserve the proof.',
);

liveAction.liveComponent.emit('request:started', { actions: [{ name: 'validatePasswords' }] });
liveAction.liveComponent.emit('render:finished', liveAction.liveComponent);
assert.equal(
    inputs.get('live_action_payload').value,
    'live-action-proof',
    'A partial Live action must preserve the proof.',
);

liveAction.liveComponent.emit('request:started', { actions: [{ name: 'refreshPreview' }] });
liveAction.liveComponent.emit('render:finished', liveAction.liveComponent);
assert.equal(
    inputs.get('live_action_payload').value,
    'live-action-proof',
    'An unrelated Live action must preserve the proof.',
);

const simpleDomSubmit = liveAction.form.emit('submit');
liveAction.liveComponent.emit('request:started', { actions: [] });
liveAction.liveComponent.emit('render:finished', liveAction.liveComponent);
assert.equal(simpleDomSubmit.prevented, false);
assert.equal(
    inputs.get('live_action_payload').value,
    'live-action-proof',
    'A DOM submit without an explicitly configured final Live action must preserve the proof.',
);

liveAction.liveComponent.emit('request:started', { actions: [{ name: 'submitRegisterConfirm' }] });
assert.equal(
    liveAction.controller.submissionPending,
    true,
    'Only an explicitly configured final Live action may arm the proof reset.',
);
liveAction.liveComponent.emit('render:finished', liveAction.liveComponent);
assert.equal(inputs.get('live_action_payload').value, '');
assert.equal(
    liveAction.widget.resetCalls,
    1,
    'The response to the final Live action must reset the consumed proof.',
);
prototype.disconnect.call(liveAction.controller);

const minimumFillTimeMs = 1_200;
const initialEnvelopeIssuedAt = 1_000;
const retryEnvelopeIssuedAt = 3_000;
const retryProofSolvedAt = 3_450;
const liveRetry = createController('live_retry_payload', 'live_retry_honeypot', {
    finalLiveActions: ['submitRegisterConfirm'],
    challengeUrl: '/_altcha/challenge/security.registration_confirm?formToken=envelope-at-1000',
});
assert.equal(
    liveRetry.controller.configuredChallenges.at(-1),
    '/_altcha/challenge/security.registration_confirm?formToken=envelope-at-1000',
);

liveRetry.liveComponent.emit('request:started', { actions: [{ name: 'submitRegisterConfirm' }] });
liveRetry.widget.dataset.cpcreationAltchaChallengeUrl =
    '/_altcha/challenge/security.registration_confirm?formToken=envelope-at-3000';
liveRetry.liveComponent.emit('render:finished', liveRetry.liveComponent);

assert.equal(liveRetry.controller.submissionPending, false);
assert.equal(inputs.get('live_retry_payload').value, '');
assert.equal(liveRetry.widget.resetCalls, 1);
assert.equal(
    liveRetry.controller.configuredChallenges.at(-1),
    '/_altcha/challenge/security.registration_confirm?formToken=envelope-at-3000',
    'After a rejected final action, the widget must use the envelope supplied by the Live render.',
);

liveRetry.widget.listeners.get('verified')({ detail: { payload: 'proof-for-envelope-at-3000' } });
assert.equal(inputs.get('live_retry_payload').value, 'proof-for-envelope-at-3000');
liveRetry.liveComponent.emit('request:started', { actions: [{ name: 'submitRegisterConfirm' }] });
assert.equal(liveRetry.controller.submissionPending, true);
assert.equal(
    retryProofSolvedAt - retryEnvelopeIssuedAt < minimumFillTimeMs,
    true,
    'The fresh envelope restarts minFill even though the original form has been open long enough.',
);
assert.equal(
    retryProofSolvedAt - initialEnvelopeIssuedAt >= minimumFillTimeMs,
    true,
    'The same retry is old enough when measured from the original valid envelope.',
);
assert.equal(
    retryEnvelopeIssuedAt + minimumFillTimeMs,
    4_200,
    'The widget and the Docker service must use the same millisecond minFill boundary.',
);
prototype.disconnect.call(liveRetry.controller);

const one = createController('form_one_payload', 'form_one_honeypot', {
    finalLiveActions: ['submitOne'],
});
const two = createController('form_two_payload', 'form_two_honeypot');
assert.equal(inputs.get('form_one_payload').value, '');
assert.equal(inputs.get('form_two_payload').value, '');
assert.equal(inputs.get('form_one_honeypot').value, '');
assert.equal(inputs.get('form_two_honeypot').value, '');
assert.equal(one.controller.configureCalls, 1);
assert.deepEqual(inputs.get('form_one_honeypot').events, ['input', 'change']);
inputs.get('form_one_honeypot').events = [];
one.controller.syncHoneypot();
assert.deepEqual(
    inputs.get('form_one_honeypot').events,
    ['input', 'change'],
    'The Live model must be resynchronized when Morphdom keeps the empty DOM value but resets its props.',
);
assert.equal(one.attributes.get('data-loading'), 'addAttribute(inert)');
assert.deepEqual([...one.widget.listeners.keys()].sort(), ['error', 'expired', 'statechange', 'verified']);
assert.deepEqual(one.form.registrations[0], ['add', 'submit', true]);

one.widget.listeners.get('verified')({ detail: { payload: 'proof-one' } });
two.widget.listeners.get('verified')({ detail: { payload: 'proof-two' } });
assert.equal(inputs.get('form_one_payload').value, 'proof-one');
assert.equal(inputs.get('form_two_payload').value, 'proof-two');

const firstSubmit = one.form.emit('submit');
assert.equal(firstSubmit.prevented, false);
one.liveComponent.emit('request:started', { actions: [{ name: 'submitOne' }] });
assert.equal(one.widget.loading.value, 'addAttribute(data-altcha-live-loading)');
const duplicateSubmit = one.form.emit('submit');
assert.equal(duplicateSubmit.prevented, true, 'A second submit must be stopped before replaying one proof.');
assert.equal(duplicateSubmit.propagationStopped, true);

one.widget.loading.value = 'false';
one.liveComponent.emit('render:finished', one.liveComponent);
assert.equal(inputs.get('form_one_payload').value, '');
assert.equal(inputs.get('form_one_honeypot').value, '');
assert.equal(one.widget.resetCalls, 1);
assert.equal(one.controller.configureCalls, 2, 'A Live reset must retry a transient ALTCHA chunk failure.');

one.widget.listeners.get('verified')({ detail: { payload: 'proof-after-invalid-business-data' } });
const submitAfterInvalidBusinessData = one.form.emit('submit');
assert.equal(
    submitAfterInvalidBusinessData.prevented,
    false,
    'A LiveComponent validation response must allow a fresh proof without reloading the page.',
);
one.liveComponent.emit('request:started', { actions: [{ name: 'submitOne' }] });
one.liveComponent.emit('response:error', {}, { displayError: true });
assert.equal(inputs.get('form_one_payload').value, '');
assert.equal(one.widget.resetCalls, 2);

const resetOne = { type: 'cpcreation-altcha:reset', detail: { form: one.form }, target: globalThis };
one.controller.reset(resetOne);
two.controller.reset(resetOne);
assert.equal(inputs.get('form_one_payload').value, '');
assert.equal(inputs.get('form_two_payload').value, 'proof-two', 'A reset must not affect another form.');
assert.equal(one.widget.resetCalls, 3);
assert.equal(two.widget.resetCalls, 0);

one.widget.listeners.get('verified')({ detail: { payload: 'proof-after-reset' } });
const submitAfterReset = one.form.emit('submit');
assert.equal(submitAfterReset.prevented, false, 'A targeted success reset must allow a fresh proof.');

prototype.disconnect.call(one.controller);
assert.equal(one.widget.listeners.size, 0);
assert.equal(one.form.listeners.has('submit:true'), false);
assert.equal(one.form.registrations.at(-2)[0], 'remove');
assert.equal(inputs.get('form_one_payload').value, '');
assert.deepEqual(one.liveComponent.hooks.get('render:finished'), []);

two.widget.listeners.get('statechange')({ detail: { state: 'verifying', payload: 'proof-two' } });
assert.equal(inputs.get('form_two_payload').value, '');
prototype.disconnect.call(two.controller);

const bot = createController('form_bot_payload', 'form_bot_honeypot');
bot.honeypot.value = 'https://spam.invalid';
bot.form.emit('submit');
assert.equal(inputs.get('form_bot_honeypot').value, 'https://spam.invalid');
prototype.disconnect.call(bot.controller);

const missing = createController('form_missing_payload', 'form_missing_honeypot', { hasHoneypot: false });
assert.equal(
    inputs.get('form_missing_honeypot').value,
    'uninitialized',
    'A missing opaque honeypot target must stay fail-closed.',
);
prototype.disconnect.call(missing.controller);

const controllerSource = readFileSync(
    new URL('../../assets/controllers/altcha_controller.js', import.meta.url),
    'utf8',
);
assert.match(controllerSource, /algorithms\.set\(['"]ARGON2ID['"]/);
assert.match(controllerSource, /altcha\/workers\/argon2id/);
assert.doesNotMatch(controllerSource, /^import ['"]altcha/m);
assert.equal(
    /minimumDuration:\s*\{\s*type:\s*Number,\s*default:\s*500\s*\}/u.test(controllerSource),
    true,
    'The controller must keep ALTCHA default timing when a scope does not impose minFill.',
);
assert.equal(
    /minDuration:\s*this\.minimumDurationValue/u.test(controllerSource),
    true,
    'The widget must wait for the signed scope minFill policy before announcing a green check.',
);
