function ValidationRequest(id, name = 'enquiry') {
    this.id = id;
    this.name = name;
    this.state = null;
    this.state = null;

    this.form = null;
    this.validationContainer = null;
    this.captchaContainer = null;

    this.field = null;
    this.action = null;
    this.key = null;
    this.browser = null;

    this.init = function () {
        this.setState('loading');
        this.setBrowser();
        this.setForm();
        this.setValidationContainer();
    }

    this.setState = function (state) {
        this.state = state;
        window.dispatchEvent(new CustomEvent(`validation_request:${state}`, {
            id: this.id,
            state: state,
        }));
    }

    this.setBrowser = function () {
        this.browser = navigator.userAgent;
    }

    this.isFireFox = function () {
        return this.browser.includes('Firefox');
    }

    this.isGoogleCaptcha = function () {
        return typeof window.grecaptcha !== 'undefined';
    }

    this.isCloudflareCaptcha = function () {
        return typeof window.turnstile !== 'undefined';
    }

    this.setForm = function () {
        this.form = document.getElementById(this.id);
        if (! this.form) {
            throw new Error(`Form with id ${this.id} not found`);
        }
    }

    this.formIsValid = function () {
        return this.form.checkValidity();
    }

    this.setSubmitEventHandler = function () {

        // Bind the submit buttons for firefox compatibility
        let submitButtons = this.form.querySelectorAll('button[type="submit"], input[type="submit"]');
        submitButtons.forEach((button) => {
            button.addEventListener('click', (event) => {
                if (! this.formIsValid()) {
                    return;
                }

                // Only have to intercept the submit for Google
                if (this.isGoogleCaptcha()) {
                    event.preventDefault();
                    this.generateGoogleToken();
                }
            });
        });

        // Bind the form submit event for other browsers
        this.form.addEventListener('submit', (event) => {
            if (! this.formIsValid()) {
                return;
            }

            // Only have to intercept the submit for Google
            if (this.isGoogleCaptcha()) {
                event.preventDefault();
                this.generateGoogleToken();
            }
        });
    }

    this.setValidationContainer = function () {
        this.validationContainer = this.form.getElementsByClassName("validation")[0];
        if (! this.validationContainer) {
            throw new Error(`Unable to create validation container for form ${this.id}`);
        }

        // Make the ajax request
        let xmlhttp = new XMLHttpRequest();
        xmlhttp.onreadystatechange = () => {

            // When the rest finishes
            if (xmlhttp.readyState === XMLHttpRequest.DONE) {

                // If the status code is not 200, then
                if (xmlhttp.status !== 200) {
                    console.log(xmlhttp.responseText);
                    return;
                }

                // Otherwise update the container HTML
                this.validationContainer.innerHTML = xmlhttp.responseText;

                // Prepend the csrf token & set the captcha
                this.setCsrfField();
                this.setCaptcha();

                this.setState('ready');
            }
        };
        xmlhttp.open("GET", `/api/builder/validation?name=${this.name}`, true);
        xmlhttp.send();
    }

    this.getCsrfToken = function () {
        return document.head.querySelector('meta[name="csrf-token"]').content;
    }

    this.setCsrfField = function () {
        let csrfField = document.createElement("input");
        csrfField.setAttribute("type", "hidden");
        csrfField.setAttribute("name", "_token");
        csrfField.setAttribute("value", this.getCsrfToken());

        this.validationContainer.prepend(csrfField);
    }

    this.setCaptcha = function () {
        this.setCaptchaContainer();

        // If the captcha is not enabled, then bail
        if (! this.captchaIsEnabled()) {
            console.log(`Captcha not enabled for ${this.id}`);

            return;
        }

        // Otherwise
        this.checkForCaptchaScript();
        this.setField();
        this.setAction();
        this.setKey();

        // Handle Form Submit
        this.isCloudflareCaptcha()
            ? this.renderCloudflareWidget()
            : this.setSubmitEventHandler();
    }

    this.checkForCaptchaScript = function () {
        if (! this.isGoogleCaptcha() && ! this.isCloudflareCaptcha()) {
            throw 'Captcha script not loaded.';
        }
    }

    this.setCaptchaContainer = function () {
        this.captchaContainer = this.validationContainer.getElementsByClassName("captcha-container")[0];
        if (! this.captchaContainer) {
            throw new Error(`Unable to find captcha container for form ${this.id}`);
        }
    }

    this.captchaIsEnabled = function () {
        return this.captchaContainer.hasAttribute('data-captcha')
            && this.captchaContainer.getAttribute('data-captcha') === '1';
    }

    this.setField = function () {
        let fieldId = this.captchaContainer.getAttribute('data-field');

        // Scoped to this form's own container. The id is derived from the captcha action
        // alone, so two forms sharing an action mint identical ids and a document-wide
        // getElementById would hand this form the other one's input - it would then post an
        // empty token. The container is per-form, so scoping keeps them apart.
        this.field = fieldId
            ? this.captchaContainer.querySelector(`#${CSS.escape(fieldId)}`)
            : this.captchaContainer.querySelector('input[name="captcha-response"]');

        if (! this.field) {
            throw new Error(`Unable to find captcha field for form ${this.id}`);
        }
    }

    this.setAction = function () {
        this.action = this.captchaContainer.getAttribute('data-action');
        if (! this.action) {
            throw new Error(`Unable to find captcha action for form ${this.id}`);
        }
    }

    this.setKey = function () {
        this.key = this.captchaContainer.getAttribute('data-key');
        if (! this.key) {
            throw new Error(`Unable to find captcha key for form ${this.id}`);
        }
    }

    this.renderCloudflareWidget = function () {
        // Turnstile draws a real widget the visitor may have to interact with. The container
        // is hidden in markup because that was correct for invisible reCAPTCHA, but a
        // challenge inside display:none can never be completed - the callback would never
        // fire and the form would silently do nothing.
        this.revealValidationContainer();

        // this.field is already resolved by setField() from data-field, which the driver
        // answers via captcha()->fieldId(). Never re-derive the id here: under Turnstile
        // fieldId() already carries the -input suffix.
        window.turnstile.render(this.captchaContainer, {
            sitekey: this.key,
            action: this.action,
            callback: (token) => {
                this.field.value = token;
            },
        });
    }

    this.revealValidationContainer = function () {
        if (this.validationContainer.style.display === 'none') {
            this.validationContainer.style.removeProperty('display');
        }
    }

    this.generateGoogleToken = function () {
        window.grecaptcha.ready(() => {
            window.grecaptcha
                .execute(this.key, {
                    action: this.action
                })
                .then((token) => {
                    this.field.value = token;
                    this.form.submit();
                })
                .catch((error) => {
                    console.error('Recaptcha error:', error);
                });
        });
    }
}
