/**
 * Core form client — the Free submission engine.
 *
 * Edition boundary (docs/wporg-free-runtime-separation.md §7): this class
 * contains no paid behavior. Pro features attach through enhancers — plain
 * functions `(client) => void` installed by createFormClient() — using the
 * public surface (collectValues, setFieldError, buildPayload, …) and the hook
 * points below:
 *
 *   payload      ({ data, files })      mutate the outgoing payload
 *   serverErrors (fields)               react to server-side field errors
 *   response     (result) => boolean    handle a success response; return true
 *                                       to stop the default success flow
 *   success      (result)               after the default success flow ran
 *
 * The Free client deliberately ignores payment and redirect response keys —
 * a properly separated Free server never returns them.
 */
import { validateAdvanced } from "./validation-rules.js";
import { I18N } from "./i18n.js";

export class CoreFormClient {
    constructor(wrapper, config) {
        this.wrapper = wrapper;
        this.formElement = wrapper.querySelector(".formatrica__form");
        this.formId = Number(wrapper.dataset.formId || 0);
        this.messageEl = this.formElement.querySelector(
            ".formatrica__messages",
        );
        this.submitButton = this.formElement.querySelector(
            ".formatrica__submit",
        );

        this.config = config;
        this.restUrl = config.restUrl;
        this.successMessage =
            this.formElement.dataset.successMessage ||
            config.successMessage ||
            I18N.thanks || "Thanks!";
        this.errorMessage =
            this.formElement.dataset.errorMessage ||
            config.errorMessage ||
            I18N.submitFailed || "Submission failed. Please try again.";

        this.captchaConfig = {
            provider: wrapper.dataset.captchaProvider || "none",
            siteKey: wrapper.dataset.captchaSiteKey || "",
        };

        this.scriptLoader = config.scriptLoader;
        this.isSubmitting = false;

        this.hooks = {
            payload: [],
            serverErrors: [],
            response: [],
            success: [],
        };
    }

    addHook(name, fn) {
        if (this.hooks[name]) {
            this.hooks[name].push(fn);
        }
    }

    /** Run a hook chain; returns true when any handler returned true. */
    runHooks(name, ...args) {
        let handled = false;
        (this.hooks[name] || []).forEach((fn) => {
            if (fn(...args) === true) {
                handled = true;
            }
        });
        return handled;
    }

    init() {
        // Preload captcha script if needed
        if (this.captchaConfig.provider !== "none") {
            this.scriptLoader
                .loadCaptchaScript(
                    this.captchaConfig.provider,
                    this.captchaConfig.siteKey,
                )
                .catch(() => {
                    /* noop - handled during submission */
                });
        }

        // Bind submit handler
        this.formElement.addEventListener("submit", (e) =>
            this.handleSubmit(e),
        );

        // Instant on-blur feedback for advanced field rules (server stays
        // canonical). Mirrors app/Submission/Validation/Rule_Validator.
        this.initAdvancedValidation();
    }

    initAdvancedValidation() {
        const controls = this.formElement.querySelectorAll("[data-fmt-validation]");

        controls.forEach((control) => {
            let config = null;
            try {
                config = JSON.parse(control.dataset.fmtValidation);
            } catch (e) {
                return;
            }
            if (!config) {
                return;
            }

            control.addEventListener("blur", () => {
                const submission = this.collectValues();
                const error = validateAdvanced(config, control.value, submission);
                if (error) {
                    this.setFieldError(control.name, error);
                } else {
                    this.clearFieldError(control.name);
                }
            });

            // Clear a stale error as soon as the visitor edits the value.
            control.addEventListener("input", () => this.clearFieldError(control.name));
        });
    }

    // Current name => value map of the form's simple controls (for sameas).
    collectValues() {
        const values = {};
        this.formElement
            .querySelectorAll("input[name], textarea[name], select[name]")
            .forEach((el) => {
                if (el.type === "checkbox" || el.type === "radio") {
                    if (el.checked) {
                        values[el.name] = el.value;
                    }
                } else {
                    values[el.name] = el.value;
                }
            });
        return values;
    }

    setFieldError(name, message) {
        if (!name) {
            return;
        }
        const errorHolder =
            this.formElement.querySelector(
                `[data-error-for="${this.escapeSelector(name)}"]`,
            ) || this.ensureErrorHolder(name);
        if (!errorHolder) {
            return;
        }
        const control = this.formElement.querySelector(this.getFieldSelector(name));
        if (control) {
            control.setAttribute("aria-invalid", "true");
            const errorId = this.ensureErrorId(errorHolder, control);
            control.setAttribute("aria-describedby", errorId);
        }
        errorHolder.textContent = message;
        errorHolder.closest(".formatrica__group")?.classList.add("formatrica__group--error");
    }

    clearFieldError(name) {
        if (!name) {
            return;
        }
        const errorHolder = this.formElement.querySelector(
            `[data-error-for="${this.escapeSelector(name)}"]`,
        );
        if (!errorHolder) {
            return;
        }
        const control = this.formElement.querySelector(this.getFieldSelector(name));
        if (control) {
            control.removeAttribute("aria-invalid");
            if (control.getAttribute("aria-describedby") === errorHolder.id) {
                control.removeAttribute("aria-describedby");
            }
        }
        errorHolder.textContent = "";
        errorHolder.closest(".formatrica__group")?.classList.remove("formatrica__group--error");
    }

    async handleSubmit(event) {
        event.preventDefault();

        this.clearMessages();
        this.clearErrors();
        this.setSubmitting(true);

        try {
            const payloadBag = this.buildPayload();
            this.runHooks("payload", payloadBag);
            const { data: payload, files } = payloadBag;
            const captchaStatus = await this.attachCaptchaToken(payload);

            if (captchaStatus !== true) {
                const message =
                    typeof captchaStatus === "string"
                        ? captchaStatus
                        : this.config.captchaFailedMessage;
                this.showMessage("error", message);
                requestAnimationFrame(() => this.focusFirstInvalid());
                return;
            }

            const requestOptions = this.buildRequestOptions(payload, files);

            const response = await fetch(
                `${this.restUrl}/forms/${this.formId}/submissions`,
                requestOptions,
            );

            const result = await response.json().catch(() => ({}));

            if (!response.ok || !result.success) {
                if (result?.errors?.fields) {
                    this.applyErrors(result.errors.fields);
                    this.runHooks("serverErrors", result.errors.fields);
                }
                const message = result.message || this.errorMessage;
                this.showMessage("error", message);
                requestAnimationFrame(() => this.focusFirstInvalid());
                return;
            }

            // A response hook may take over entirely (e.g. a gateway redirect).
            if (this.runHooks("response", result)) {
                return;
            }

            this.showMessage("success", result.message || this.successMessage);
            this.formElement.reset();
            this.resetCaptcha();

            // Bubbling success signal — shells wrapping the form (e.g. a modal)
            // listen on an ancestor to react after submit.
            this.formElement.dispatchEvent(
                new CustomEvent("fmt:submit-success", { bubbles: true }),
            );

            this.runHooks("success", result);
        } catch (error) {
            this.showMessage("error", this.errorMessage);
        } finally {
            this.setSubmitting(false);
        }
    }

    buildPayload() {
        const formData = new FormData(this.formElement);
        const payload = {};
        const files = {};
        const checkboxState = {};

        formData.forEach((value, key) => {
            if (value instanceof File) {
                if (value && value.name) {
                    files[key] = value;
                }
            } else {
                payload[key] = value;
            }
        });

        this.formElement
            .querySelectorAll('input[type="checkbox"][name]')
            .forEach((checkbox) => {
                const name = checkbox.name;
                if (!checkboxState[name]) {
                    checkboxState[name] = { total: 0, values: [] };
                }
                checkboxState[name].total += 1;
                if (checkbox.checked) {
                    const value = checkbox.value || "on";
                    checkboxState[name].values.push(value);
                }
                if (
                    !checkbox.checked &&
                    checkboxState[name].total === 1 &&
                    !(name in payload)
                ) {
                    payload[name] = false;
                }
            });

        Object.entries(checkboxState).forEach(([name, state]) => {
            if (state.total > 1) {
                payload[name] = state.values;
            } else {
                payload[name] = state.values.length > 0;
            }
        });

        this.formElement
            .querySelectorAll("select[multiple][name]")
            .forEach((select) => {
                const values = Array.from(select.selectedOptions).map(
                    (option) => option.value,
                );
                payload[select.name] = values;
            });

        Object.keys(files).forEach((key) => {
            delete payload[key];
        });

        return { data: payload, files };
    }

    buildRequestOptions(payload, files) {
        const hasFiles = Object.keys(files).length > 0;

        if (!hasFiles) {
            return {
                method: "POST",
                headers: {
                    "Content-Type": "application/json",
                },
                body: JSON.stringify(payload),
            };
        }

        const formData = new FormData();
        formData.append("payload", JSON.stringify(payload));

        Object.entries(files).forEach(([name, file]) => {
            if (Array.isArray(file)) {
                // File lists: append each under files[name][] so PHP groups them.
                file.forEach((f) => formData.append(`files[${name}][]`, f));
            } else {
                formData.append(`files[${name}]`, file);
            }
        });

        return {
            method: "POST",
            body: formData,
        };
    }

    async attachCaptchaToken(payload) {
        if (!this.captchaConfig || this.captchaConfig.provider === "none") {
            delete payload._fmt_captcha_token;
            delete payload._fmt_captcha_provider;
            return true;
        }

        payload._fmt_captcha_provider = this.captchaConfig.provider;

        if (this.captchaConfig.provider === "recaptcha_v3") {
            return await this.handleRecaptchaV3(payload);
        }

        if (this.captchaConfig.provider === "turnstile") {
            return this.handleTurnstile(payload);
        }

        if (this.captchaConfig.provider === "friendlycaptcha") {
            return this.handleFriendlyCaptcha(payload);
        }

        return true;
    }

    async handleRecaptchaV3(payload) {
        if (!this.captchaConfig.siteKey) {
            return this.config.captchaFailedMessage;
        }

        try {
            await this.scriptLoader.loadCaptchaScript(
                "recaptcha_v3",
                this.captchaConfig.siteKey,
            );

            if (!window.grecaptcha || !window.grecaptcha.execute) {
                return this.config.captchaFailedMessage;
            }

            const token = await new Promise((resolve, reject) => {
                window.grecaptcha.ready(() => {
                    window.grecaptcha
                        .execute(this.captchaConfig.siteKey, {
                            action: "formatrica_submit",
                        })
                        .then(resolve)
                        .catch(reject);
                });
            });

            if (!token) {
                return this.config.captchaFailedMessage;
            }

            payload._fmt_captcha_token = token;
            const hidden = this.formElement.querySelector(
                'input[name="_fmt_captcha_token"]',
            );
            if (hidden) {
                hidden.value = token;
            }

            return true;
        } catch (error) {
            return this.config.captchaFailedMessage;
        }
    }

    async handleTurnstile(payload) {
        await this.scriptLoader.loadCaptchaScript("turnstile");
        const input = this.formElement.querySelector(
            'input[name="cf-turnstile-response"]',
        );
        const token = (
            payload["cf-turnstile-response"] ||
            input?.value ||
            ""
        ).trim();

        if (!token) {
            return this.config.captchaIncompleteMessage;
        }

        payload._fmt_captcha_token = token;
        delete payload["cf-turnstile-response"];
        const hidden = this.formElement.querySelector(
            'input[name="_fmt_captcha_token"]',
        );
        if (hidden) {
            hidden.value = token;
        }

        return true;
    }

    async handleFriendlyCaptcha(payload) {
        await this.scriptLoader.loadCaptchaScript("friendlycaptcha");
        const token = (
            payload["frc-captcha-response"] ||
            payload["frc-captcha-solution"] ||
            this.formElement.querySelector(
                'input[name="frc-captcha-response"]',
            )?.value ||
            this.formElement.querySelector(
                'input[name="frc-captcha-solution"]',
            )?.value ||
            ""
        ).trim();

        if (!token) {
            return this.config.captchaIncompleteMessage;
        }

        payload._fmt_captcha_token = token;
        delete payload["frc-captcha-response"];
        delete payload["frc-captcha-solution"];
        const hidden = this.formElement.querySelector(
            'input[name="_fmt_captcha_token"]',
        );
        if (hidden) {
            hidden.value = token;
        }

        return true;
    }

    resetCaptcha() {
        if (!this.captchaConfig || this.captchaConfig.provider === "none") {
            return;
        }

        if (
            this.captchaConfig.provider === "turnstile" &&
            window.turnstile?.reset
        ) {
            window.turnstile.reset();
        }

        if (
            this.captchaConfig.provider === "friendlycaptcha" &&
            window.friendlyChallenge?.widgets?.reset
        ) {
            try {
                window.friendlyChallenge.widgets.reset();
            } catch (error) {
                // Ignore reset issues.
            }
        }

        const hidden = this.formElement.querySelector(
            'input[name="_fmt_captcha_token"]',
        );
        if (hidden) {
            hidden.value = "";
        }
    }

    showMessage(type, text) {
        if (!this.messageEl) return;
        this.messageEl.innerHTML = "";
        const notice = document.createElement("div");
        notice.className = `formatrica__notice formatrica__notice--${type}`;
        notice.id = `fmt-message-${this.formId}`;
        notice.setAttribute("role", "status");
        notice.setAttribute("tabindex", "-1");
        notice.textContent = text;
        this.messageEl.appendChild(notice);

        requestAnimationFrame(() => {
            notice.scrollIntoView({ behavior: "smooth", block: "nearest" });
            setTimeout(() => {
                notice.focus({ preventScroll: true });
            }, 300);
        });
    }

    clearMessages() {
        if (this.messageEl) {
            this.messageEl.innerHTML = "";
        }
    }

    setSubmitting(isSubmitting) {
        this.isSubmitting = isSubmitting;
        if (this.submitButton) {
            this.submitButton.disabled = isSubmitting;
        }
        this.wrapper.classList.toggle(
            "formatrica--submitting",
            isSubmitting,
        );
    }

    applyErrors(errors) {
        let hasErrors = false;

        Object.entries(errors).forEach(([name, message]) => {
            const errorHolder =
                this.formElement.querySelector(
                    `[data-error-for="${this.escapeSelector(name)}"]`,
                ) || this.ensureErrorHolder(name);

            if (!errorHolder) {
                return;
            }

            hasErrors = true;

            const control = this.formElement.querySelector(
                this.getFieldSelector(name),
            );

            if (control) {
                control.setAttribute("aria-invalid", "true");
                const errorId = this.ensureErrorId(errorHolder, control);
                control.setAttribute("aria-describedby", errorId);
            }

            errorHolder.textContent = message;
            errorHolder
                .closest(".formatrica__group")
                ?.classList.add("formatrica__group--error");
        });

        if (hasErrors) {
            requestAnimationFrame(() => this.focusFirstInvalid());
        }
    }

    clearErrors() {
        this.formElement
            .querySelectorAll(".formatrica__group--error")
            .forEach((group) => {
                group.classList.remove("formatrica__group--error");
            });

        this.formElement
            .querySelectorAll(".formatrica__error")
            .forEach((error) => {
                const control = this.formElement.querySelector(
                    this.getFieldSelector(error.dataset.errorFor),
                );
                if (control) {
                    control.removeAttribute("aria-invalid");
                    if (
                        control.getAttribute("aria-describedby") === error.id
                    ) {
                        control.removeAttribute("aria-describedby");
                    }
                }
                error.textContent = "";
            });
    }

    ensureErrorHolder(fieldName) {
        const field = this.formElement.querySelector(
            this.getFieldSelector(fieldName),
        );
        const group = field?.closest(".formatrica__group");

        if (!group) {
            return null;
        }

        const errorHolder = document.createElement("p");
        errorHolder.className = "formatrica__error";
        errorHolder.dataset.errorFor = fieldName;
        errorHolder.setAttribute("role", "alert");
        group.appendChild(errorHolder);

        return errorHolder;
    }

    ensureErrorId(errorHolder, control) {
        if (errorHolder.id) {
            return errorHolder.id;
        }
        const baseId = control.id || control.name || "fmt-field";
        const sanitisedId = baseId.replace(/[^a-z0-9_-]/gi, "");
        const errorId = `${sanitisedId || "fmt-field"}-error`;
        errorHolder.id = errorId;
        return errorId;
    }

    focusFirstInvalid() {
        const invalid = this.formElement.querySelector(
            '[aria-invalid="true"]',
        );
        if (invalid) {
            invalid.focus({ preventScroll: false });
        }
    }

    getFieldSelector(name) {
        const escaped = this.escapeSelector(name);
        return `[name="${escaped}"], [id="${escaped}"]`;
    }

    escapeSelector(value) {
        if (window.CSS?.escape) {
            return window.CSS.escape(String(value));
        }

        return String(value).replace(
            /([ #;?%&,.+*~':"!^$\[\]\\(){}|<>@])/g,
            "\\$1",
        );
    }
}

/**
 * Build a client for one form wrapper: construct the core, install the
 * edition's enhancers, then boot. Enhancers are plain functions
 * `(client) => void` — they may bind DOM behavior immediately and/or
 * register hooks via client.addHook().
 */
export function createFormClient(wrapper, config, enhancers = []) {
    const client = new CoreFormClient(wrapper, config);
    enhancers.forEach((enhancer) => enhancer(client));
    client.init();
    return client;
}
