assets/js/controllers/email-template-form-controller.js

import { Controller } from "@hotwired/stimulus"

/**
 * Email Template Form Controller
 *
 * Manages the dynamic behaviour of the email template form:
 * - Auto-generates slug from Name if slug is empty
 * - Parses {{variable}} placeholders from template content and surfaces them
 *   in the Variables Contract tab as an authoring aid
 */
class EmailTemplateFormController extends Controller {
    static targets = [
        "availableVars", "subjectTemplate",
        "nameField", "slugField",
        "htmlTemplate", "textTemplate",
        "parsedVarsPanel", "parsedVarsList",
        "variablesSchema", "variableRows", "variableRowTemplate",
        "emptyVariableMessage", "variableName", "variableDescription",
        "variableType", "variableRequired",
    ]

    connect() {
        this._renderVariableContractRows(this._readVariableSchema())
        // Parse placeholders from existing template content on connect
        this._refreshParsedPlaceholders()
    }

    // ── Slug auto-generation ────────────────────────────────────────────────

    /**
     * Called on name field input. Auto-fills slug if slug is currently empty.
     */
    nameChanged() {
        if (!this.hasSlugFieldTarget || !this.hasNameFieldTarget) return

        const slug = this.slugFieldTarget.value.trim()
        if (slug !== '') return  // Don't overwrite a manually-entered slug

        this.slugFieldTarget.value = this._slugify(this.nameFieldTarget.value)
    }

    /**
     * Convert an arbitrary string to a valid slug (lowercase, hyphens).
     * @param {string} text
     * @returns {string}
     */
    _slugify(text) {
        return text
            .toLowerCase()
            .trim()
            .replace(/[^a-z0-9\s-]/g, '')   // strip non-alphanum
            .replace(/[\s_-]+/g, '-')         // spaces/underscores → hyphen
            .replace(/^-+|-+$/g, '')          // trim leading/trailing hyphens
    }

    // ── Template placeholder parsing ────────────────────────────────────────

    /**
     * Called when html_template or text_template content changes.
     * Refreshes the parsed-placeholders helper in the Variables Contract tab.
     */
    templateChanged() {
        this._refreshParsedPlaceholders()
    }

    /**
     * Add a blank variable contract row.
     */
    addVariable() {
        this._appendVariableRow({
            name: '',
            description: '',
            type: 'string',
            required: false,
        }, true)
        this._refreshVariableRowsState()
    }

    /**
     * Add a detected placeholder to the variable contract.
     *
     * @param {string} name Placeholder name
     */
    addVariableFromPlaceholder(name) {
        if (!name || this._schemaHasVariable(name)) return

        this._appendVariableRow({
            name,
            description: this._humanizeVariableName(name),
            type: 'string',
            required: true,
        }, true)
        this.serializeVariableContract()
        this._refreshVariableRowsState()
        this._refreshParsedPlaceholders()
    }

    /**
     * Remove a variable contract row.
     *
     * @param {Event} event
     */
    removeVariable(event) {
        const row = event.currentTarget.closest('[data-variable-contract-row]')
        if (!row) return

        row.remove()
        this.serializeVariableContract()
        this._refreshVariableRowsState()
        this._refreshParsedPlaceholders()
    }

    /**
     * Keep the hidden JSON field up to date as rows are edited.
     */
    variableContractChanged() {
        this.serializeVariableContract()
        this._refreshVariableRowsState()
        this._refreshParsedPlaceholders()
    }

    /**
     * Serialize the row editor into the backend JSON field.
     */
    serializeVariableContract() {
        if (!this.hasVariablesSchemaTarget) return

        const schema = this._collectVariableContractRows()
        this.variablesSchemaTarget.value = schema.length > 0
            ? JSON.stringify(schema)
            : ''
    }

    /**
     * Parse {{varName}} placeholders from the HTML and text template fields
     * and display them as a helper in the Variables Contract tab.
     */
    _refreshParsedPlaceholders() {
        if (!this.hasParsedVarsPanelTarget || !this.hasParsedVarsListTarget) return

        const htmlContent = this.hasHtmlTemplateTarget ? this.htmlTemplateTarget.value : ''
        const textContent = this.hasTextTemplateTarget ? this.textTemplateTarget.value : ''
        const combined = htmlContent + ' ' + textContent

        const names = this._extractPlaceholders(combined)

        if (names.length === 0) {
            this.parsedVarsPanelTarget.style.display = 'none'
            return
        }

        this.parsedVarsPanelTarget.style.display = ''
        this.parsedVarsListTarget.innerHTML = ''

        names.forEach(name => {
            const button = document.createElement('button')
            button.type = 'button'
            button.className = this._schemaHasVariable(name)
                ? 'btn btn-sm btn-success'
                : 'btn btn-sm btn-outline-primary'
            button.textContent = '{{' + name + '}}'
            button.disabled = this._schemaHasVariable(name)
            button.setAttribute('aria-label', this._schemaHasVariable(name)
                ? `${name} is already in the variable contract`
                : `Add ${name} to the variable contract`)
            button.addEventListener('click', () => this.addVariableFromPlaceholder(name))
            this.parsedVarsListTarget.appendChild(button)
        })
    }

    /**
     * Extract unique placeholder names from a template string.
     * Matches {{varName}} and {{#if varName}} / {{/if}} / {{else}} patterns.
     * Returns names sorted alphabetically, excluding control keywords.
     *
     * @param {string} content
     * @returns {string[]}
     */
    _extractPlaceholders(content) {
        const CONTROL_KEYWORDS = new Set(['if', 'else', '/if', '#if', 'true', 'false'])
        const pattern = /\{\{\s*([a-zA-Z_][a-zA-Z0-9_.]*)\s*\}\}/g
        const conditionPattern = /\{\{#if\s+(.+?)\}\}/gs
        const seen = new Set()
        let match

        while ((match = pattern.exec(content)) !== null) {
            const name = match[1].trim()
            if (!CONTROL_KEYWORDS.has(name)) {
                seen.add(name)
            }
        }

        while ((match = conditionPattern.exec(content)) !== null) {
            const conditionWithoutStrings = match[1].replace(/["'][^"']*["']/g, '')
            const conditionVariablePattern = /\$?\b([a-zA-Z_][a-zA-Z0-9_]*)\b/g
            let conditionMatch

            while ((conditionMatch = conditionVariablePattern.exec(conditionWithoutStrings)) !== null) {
                const name = conditionMatch[1].trim()
                if (!CONTROL_KEYWORDS.has(name)) {
                    seen.add(name)
                }
            }
        }

        return Array.from(seen).sort()
    }

    /**
     * Parse the hidden JSON schema field.
     *
     * @returns {Array<object>}
     */
    _readVariableSchema() {
        if (!this.hasVariablesSchemaTarget || this.variablesSchemaTarget.value.trim() === '') {
            return []
        }

        try {
            const parsed = JSON.parse(this.variablesSchemaTarget.value)
            return this._normalizeSchemaEntries(parsed)
        } catch (error) {
            return []
        }
    }

    /**
     * Normalize supported schema shapes into row entries.
     *
     * @param {Array<object>|object} entries
     * @returns {Array<object>}
     */
    _normalizeSchemaEntries(entries) {
        if (Array.isArray(entries)) {
            return entries
                .filter(entry => entry && typeof entry === 'object')
                .map(entry => ({
                    name: entry.name || '',
                    description: entry.description || '',
                    type: entry.type || 'string',
                    required: Boolean(entry.required),
                }))
        }

        if (!entries || typeof entries !== 'object') {
            return []
        }

        return Object.entries(entries).map(([name, entry]) => ({
            name,
            description: entry?.description || '',
            type: entry?.type || 'string',
            required: Boolean(entry?.required),
        }))
    }

    /**
     * Render schema entries into editor rows.
     *
     * @param {Array<object>} entries
     */
    _renderVariableContractRows(entries) {
        if (!this.hasVariableRowsTarget || !this.hasVariableRowTemplateTarget) return

        this.variableRowsTarget.innerHTML = ''
        entries.forEach(entry => this._appendVariableRow(entry))
        this.serializeVariableContract()
        this._refreshVariableRowsState()
    }

    /**
     * Append one variable contract row.
     *
     * @param {object} entry
     * @param {boolean} shouldFocus Whether to focus the new row name input
     */
    _appendVariableRow(entry, shouldFocus = false) {
        if (!this.hasVariableRowsTarget || !this.hasVariableRowTemplateTarget) return

        const fragment = this.variableRowTemplateTarget.content.cloneNode(true)
        const row = fragment.querySelector('[data-variable-contract-row]')
        const name = row.querySelector('[data-email-template-form-target~="variableName"]')
        const description = row.querySelector('[data-email-template-form-target~="variableDescription"]')
        const type = row.querySelector('[data-email-template-form-target~="variableType"]')
        const required = row.querySelector('[data-email-template-form-target~="variableRequired"]')

        name.value = entry.name || ''
        description.value = entry.description || ''
        type.value = entry.type || 'string'
        required.checked = Boolean(entry.required)

        this.variableRowsTarget.appendChild(fragment)
        if (shouldFocus) {
            name.focus()
        }
    }

    /**
     * Collect valid variable contract rows for serialization.
     *
     * @returns {Array<object>}
     */
    _collectVariableContractRows() {
        if (!this.hasVariableRowsTarget) return []

        return Array.from(this.variableRowsTarget.querySelectorAll('[data-variable-contract-row]'))
            .map(row => {
                const name = row.querySelector('[data-email-template-form-target~="variableName"]')?.value.trim() || ''
                const description = row
                    .querySelector('[data-email-template-form-target~="variableDescription"]')?.value.trim() || ''
                const type = row.querySelector('[data-email-template-form-target~="variableType"]')?.value || 'string'
                const required = row.querySelector('[data-email-template-form-target~="variableRequired"]')?.checked || false

                return { name, description, type, required }
            })
            .filter(entry => entry.name !== '')
    }

    /**
     * Check whether the row editor already has a variable.
     *
     * @param {string} name Variable name
     * @returns {boolean}
     */
    _schemaHasVariable(name) {
        return this._collectVariableContractRows().some(entry => entry.name === name)
    }

    /**
     * Toggle empty-state messaging.
     */
    _refreshVariableRowsState() {
        if (!this.hasEmptyVariableMessageTarget || !this.hasVariableRowsTarget) return

        this.emptyVariableMessageTarget.classList.toggle(
            'd-none',
            this.variableRowsTarget.querySelector('[data-variable-contract-row]') !== null,
        )
    }

    /**
     * Create a readable default description from a variable name.
     *
     * @param {string} name Variable name
     * @returns {string}
     */
    _humanizeVariableName(name) {
        return name
            .replace(/_/g, ' ')
            .replace(/([a-z])([A-Z])/g, '$1 $2')
            .replace(/\b\w/g, character => character.toUpperCase())
    }
}

// Add to global controllers registry
if (!window.Controllers) {
    window.Controllers = {};
}
window.Controllers["email-template-form"] = EmailTemplateFormController;