{% load i18n %}
{% load inventree_extras %}

/* globals
    createNewModal,
    inventreeFormDataUpload,
    inventreeGet,
    inventreePut,
    modalEnable,
    modalShowSubmitButton,
    renderBuild,
    renderCompany,
    renderManufacturerPart,
    renderOwner,
    renderPart,
    renderPartCategory,
    renderPartParameterTemplate,
    renderStockItem,
    renderStockLocation,
    renderSupplierPart,
    renderUser,
    showAlertOrCache,
    showApiError,
*/

/* exported
    clearFormInput,
    disableFormInput,
    enableFormInput,
    hideFormInput,
    setFormInputPlaceholder,
    setFormGroupVisibility,
    showFormInput,
*/

/**
 *
 * This file contains code for rendering (and managing) HTML forms
 * which are served via the django-drf API.
 *
 * The django DRF library provides an OPTIONS method for each API endpoint,
 * which allows us to introspect the available fields at any given endpoint.
 *
 * The OPTIONS method provides the following information for each available field:
 *
 * - Field name
 * - Field label (translated)
 * - Field help text (translated)
 * - Field type
 * - Read / write status
 * - Field required status
 * - min_value / max_value
 *
 */

// Set global default theme for select2
$.fn.select2.defaults.set('theme', 'bootstrap-5');

/*
 * Return true if the OPTIONS specify that the user
 * can perform a GET method at the endpoint.
 */
function canView(OPTIONS) {
    
    if ('actions' in OPTIONS) {
        return ('GET' in OPTIONS.actions);
    } else {
        return false;
    }
}


/*
 * Return true if the OPTIONS specify that the user
 * can perform a POST method at the endpoint
 */
function canCreate(OPTIONS) {

    if ('actions' in OPTIONS) {
        return ('POST' in OPTIONS.actions);
    } else {
        return false;
    }
}


/*
 * Return true if the OPTIONS specify that the user
 * can perform a PUT or PATCH method at the endpoint
 */
function canChange(OPTIONS) {
    
    if ('actions' in OPTIONS) {
        return ('PUT' in OPTIONS.actions || 'PATCH' in OPTIONS.actions);
    } else {
        return false;
    }
}


/*
 * Return true if the OPTIONS specify that the user
 * can perform a DELETE method at the endpoint
 */
function canDelete(OPTIONS) {

    if ('actions' in OPTIONS) {
        return ('DELETE' in OPTIONS.actions);
    } else {
        return false;
    }
}


/*
 * Get the API endpoint options at the provided URL,
 * using a HTTP options request.
 */
function getApiEndpointOptions(url, callback) {

    if (!url) {
        return;
    }

    // Return the ajax request object
    $.ajax({
        url: url,
        type: 'OPTIONS',
        contentType: 'application/json',
        dataType: 'json',
        accepts: {
            json: 'application/json',
        },
        success: callback,
        error: function(xhr) {
            // TODO: Handle error
            console.log(`ERROR in getApiEndpointOptions at '${url}'`);
            showApiError(xhr, url);
        }
    });
}


/*
 * Construct a 'creation' (POST) form, to create a new model in the database.
 * 
 * arguments:
 * - fields: The 'actions' object provided by the OPTIONS endpoint
 * 
 * options:
 * - 
 */
function constructCreateForm(fields, options) {

    // Check if default values were provided for any fields
    for (const name in fields) {
    
        var field = fields[name];

        var field_options = options.fields[name] || {};

        // If a 'value' is not provided for the field,
        if (field.value == null) {
            
            if ('value' in field_options) {
                // Client has specified the default value for the field
                field.value = field_options.value;
            } else if (field.default != null) {
                // OPTIONS endpoint provided default value for this field
                field.value = field.default;
            }
        }
    }

    // We should have enough information to create the form!
    constructFormBody(fields, options);
}


/*
 * Construct a 'change' (PATCH) form, to create a new model in the database.
 * 
 * arguments:
 * - fields: The 'actions' object provided by the OPTIONS endpoint
 * 
 * options:
 * - 
 */
function constructChangeForm(fields, options) {

    // Request existing data from the API endpoint
    $.ajax({
        url: options.url,
        data: options.params || {},
        type: 'GET',
        contentType: 'application/json',
        dataType: 'json',
        accepts: {
            json: 'application/json',
        },
        success: function(data) {
            
            // An optional function can be provided to process the returned results,
            // before they are rendered to the form
            if (options.processResults) {
                var processed = options.processResults(data, fields, options);
                
                // If the processResults function returns data, it will be stored
                if (processed) {
                    data = processed;
                }
            }

            // Push existing 'value' to each field
            for (const field in data) {

                if (field in fields) {
                    fields[field].value = data[field];
                }
            }

            // Store the entire data object
            options.instance = data;
            
            constructFormBody(fields, options);
        },
        error: function(xhr) {
            // TODO: Handle error here
            console.log(`ERROR in constructChangeForm at '${options.url}'`);

            showApiError(xhr, options.url);
        }
    });
}


/*
 * Construct a 'delete' form, to remove a model instance from the database.
 * 
 * arguments:
 * - fields: The 'actions' object provided by the OPTIONS request
 * - options: The 'options' object provided by the client
 */
function constructDeleteForm(fields, options) {

    // Force the "confirm" property if not set
    if (!('confirm' in options)) {
        options.confirm = true;
    }

    // Request existing data from the API endpoint
    // This data can be used to render some information on the form
    $.ajax({
        url: options.url,
        type: 'GET',
        contentType: 'application/json',
        dataType: 'json',
        accepts: {
            json: 'application/json',
        },
        success: function(data) {

            // Store the instance data
            options.instance = data;

            constructFormBody(fields, options);
        },
        error: function(xhr) {
            // TODO: Handle error here
            console.log(`ERROR in constructDeleteForm at '${options.url}`);

            showApiError(xhr, options.url);
        }
    });
}


/*
 * Request API OPTIONS data from the server,
 * and construct a modal form based on the response.
 * 
 * url: API URL which defines form data
 * options:
 * - method: The HTTP method e.g. 'PUT', 'POST', 'DELETE' (default='PATCH')
 * - title: The form title
 * - submitText: Text for the "submit" button
 * - closeText: Text for the "close" button
 * - fields: list of fields to display, with the following options
 *      - filters: API query filters
 *      - onEdit: callback when field is edited
 *      - secondary: Define a secondary modal form for this field
 *      - label: Specify custom label
 *      - help_text: Specify custom help_text
 *      - placeholder: Specify custom placeholder text
 *      - value: Specify initial value
 *      - hidden: Set to true to hide the field
 *      - icon: font-awesome icon to display before the field
 *      - prefix: Custom HTML prefix to display before the field
 * - data: map of data to fill out field values with
 * - focus: Name of field to focus on when modal is displayed
 * - preventClose: Set to true to prevent form from closing on success
 * - onSuccess: callback function when form action is successful
 * - follow: If a 'url' is provided by the API on success, redirect to it
 * - redirect: A URL to redirect to after form success
 * - reload: Set to true to reload the current page after form success
 * - confirm: Set to true to require a "confirm" button
 * - confirmText: Text for confirm button (default = "Confirm")
 * 
 */
function constructForm(url, options) {

    // An "empty" form will be defined locally
    if (url == null) {
        constructFormBody({}, options);
    }

    options.fields = options.fields || {};

    // Save the URL 
    options.url = url;

    // Default HTTP method
    options.method = options.method || 'PATCH';

    // Default "groups" definition
    options.groups = options.groups || {};
    options.current_group = null;

    // Construct an "empty" data object if not provided
    if (!options.data) {
        options.data = {};
    }

    // Request OPTIONS endpoint from the API
    getApiEndpointOptions(url, function(OPTIONS) {

        /*
         * Determine what "type" of form we want to construct,
         * based on the requested action.
         * 
         * First we must determine if the user has the correct permissions!
         */

        switch (options.method) {
        case 'POST':
            if (canCreate(OPTIONS)) {
                constructCreateForm(OPTIONS.actions.POST, options);
            } else {
                // User does not have permission to POST to the endpoint
                showMessage('{% trans "Action Prohibited" %}', {
                    style: 'danger',
                    details: '{% trans "Create operation not allowed" %}',
                    icon: 'fas fa-user-times',
                });
            
                console.log(`'POST action unavailable at ${url}`);
            }
            break;
        case 'PUT':
        case 'PATCH':
            if (canChange(OPTIONS)) {
                constructChangeForm(OPTIONS.actions.PUT, options);
            } else {
                // User does not have permission to PUT/PATCH to the endpoint
                showMessage('{% trans "Action Prohibited" %}', {
                    style: 'danger',
                    details: '{% trans "Update operation not allowed" %}',
                    icon: 'fas fa-user-times',
                });
            
                console.log(`${options.method} action unavailable at ${url}`);
            }
            break;
        case 'DELETE':
            if (canDelete(OPTIONS)) {
                constructDeleteForm(OPTIONS.actions.DELETE, options);
            } else {
                // User does not have permission to DELETE to the endpoint
                showMessage('{% trans "Action Prohibited" %}', {
                    style: 'danger',
                    details: '{% trans "Delete operation not allowed" %}',
                    icon: 'fas fa-user-times',
                });
            
                console.log(`DELETE action unavailable at ${url}`);
            }
            break;
        case 'GET':
            if (canView(OPTIONS)) {
                // TODO?
            } else {
                // User does not have permission to GET to the endpoint
                showMessage('{% trans "Action Prohibited" %}', {
                    style: 'danger',
                    details: '{% trans "View operation not allowed" %}',
                    icon: 'fas fa-user-times',
                });
            
                console.log(`GET action unavailable at ${url}`);
            }
            break;
        default:
            console.log(`constructForm() called with invalid method '${options.method}'`);
            break;
        }
    });
}


/*
 * Construct a modal form based on the provided options
 * 
 * arguments:
 * - fields: The endpoint description returned from the OPTIONS request
 * - options: form options object provided by the client.
 */
function constructFormBody(fields, options) {

    var html = '';

    // Client must provide set of fields to be displayed,
    // otherwise *all* fields will be displayed
    var displayed_fields = options.fields || fields;

    // Handle initial data overrides
    if (options.data) {
        for (const field in options.data) {

            if (field in fields) {
                fields[field].value = options.data[field];
            }
        }
    }

    // Initialize an "empty" field for each specified field
    for (field in displayed_fields) {
        if (!(field in fields)) {
            fields[field] = {};
        }
    }

    // Provide each field object with its own name
    for (field in fields) {
        fields[field].name = field;
        
        // If any "instance_filters" are defined for the endpoint, copy them across (overwrite)
        if (fields[field].instance_filters) {
            fields[field].filters = Object.assign(fields[field].filters || {}, fields[field].instance_filters);
        }

        var field_options = displayed_fields[field];

        // Copy custom options across to the fields object
        if (field_options) {

            // Override existing query filters (if provided!)
            fields[field].filters = Object.assign(fields[field].filters || {}, field_options.filters);

            for (var opt in field_options) {

                var val = field_options[opt];

                if (opt == 'filters') {
                    // ignore filters (see above)
                } else if (opt == 'icon') {
                    // Specify custom icon
                    fields[field].prefix = `<span class='fas ${val}'></span>`;
                } else {
                    fields[field][opt] = field_options[opt];
                }
            }
        }
    }

    // Construct an ordered list of field names
    var field_names = [];

    for (var name in displayed_fields) {

        field_names.push(name);

        // Field not specified in the API, but the client wishes to add it!
        if (!(name in fields)) {
            fields[name] = displayed_fields[name];
        }
    }

    // Push the ordered field names into the options,
    // allowing successive functions to access them.
    options.field_names = field_names;

    // Render selected fields

    for (var idx = 0; idx < field_names.length; idx++) {

        var field_name = field_names[idx];

        var field = fields[field_name];

        switch (field.type) {
        // Skip field types which are simply not supported
        case 'nested object':
            continue;
        default:
            break;
        }
        
        html += constructField(field_name, field, options);
    }

    if (options.current_group) {
        // Close out the current group
        html += `</div></div>`;
    }

    // Create a new modal if one does not exists
    if (!options.modal) {
        options.modal = createNewModal(options);
    }

    var modal = options.modal;

    modalEnable(modal, true);
    
    // Insert generated form content
    $(modal).find('#form-content').html(html);

    if (options.preFormContent) {
        $(modal).find('#pre-form-content').html(options.preFormContent);
    }

    if (options.postFormContent) {
        $(modal).find('#post-form-content').html(options.postFormContent);
    }
    
    // Clear any existing buttons from the modal
    $(modal).find('#modal-footer-buttons').html('');

    // Insert "confirm" button (if required)
    if (options.confirm) {
        insertConfirmButton(options);
    }

    // Display the modal
    $(modal).modal('show');

    updateFieldValues(fields, options);

    // Setup related fields
    initializeRelatedFields(fields, options);

    // Attach edit callbacks (if required)
    addFieldCallbacks(fields, options);

    // Attach clear callbacks (if required)
    addClearCallbacks(fields, options);

    modalShowSubmitButton(modal, true);

    $(modal).on('click', '#modal-form-submit', function() {

        // Immediately disable the "submit" button,
        // to prevent the form being submitted multiple times!
        $(options.modal).find('#modal-form-submit').prop('disabled', true);

        // Run custom code before normal form submission
        if (options.beforeSubmit) {
            options.beforeSubmit(fields, options);
        }

        // Run custom code instead of normal form submission
        if (options.onSubmit) {
            options.onSubmit(fields, options);
        } else {
            submitFormData(fields, options);
        }
    });

    initializeGroups(fields, options);

    if (options.afterRender) {
        // Custom callback function after form rendering
        options.afterRender(fields, options);
    }

    // Scroll to the top
    $(options.modal).find('.modal-form-content-wrapper').scrollTop(0);
}


// Add a "confirm" checkbox to the modal
// The "submit" button will be disabled unless "confirm" is checked
function insertConfirmButton(options) {

    var message = options.confirmMessage || '{% trans "Confirm" %}';

    var html = `
    <div class="form-check form-switch">
        <input class="form-check-input" type="checkbox" id="modal-confirm">
        <label class="form-check-label" for="modal-confirm">${message}</label>
    </div>
    `;

    $(options.modal).find('#modal-footer-buttons').append(html);

    // Disable the 'submit' button
    $(options.modal).find('#modal-form-submit').prop('disabled', true);

    // Trigger event
    $(options.modal).find('#modal-confirm').change(function() {
        var enabled = this.checked;

        $(options.modal).find('#modal-form-submit').prop('disabled', !enabled);
    });
}


/*
 * Extract all specified form values as a single object
 */
function extractFormData(fields, options) {

    var data = {};

    for (var idx = 0; idx < options.field_names.length; idx++) {
        
        var name = options.field_names[idx];

        var field = fields[name] || null;

        if (!field) continue;

        if (field.type == 'candy') continue;

        data[name] = getFormFieldValue(name, field, options);
    }

    return data;
}


/*
 * Submit form data to the server.
 * 
 */
function submitFormData(fields, options) {

    // Form data to be uploaded to the server
    // Only used if file / image upload is required
    var form_data = new FormData();

    var data = {};

    var has_files = false;

    var data_valid = true;

    var data_errors = {};

    // Extract values for each field
    for (var idx = 0; idx < options.field_names.length; idx++) {

        var name = options.field_names[idx];

        var field = fields[name] || null;

        // Ignore visual fields
        if (field && field.type == 'candy') continue;

        if (field) {

            switch (field.type) {
            // Ensure numerical fields are "valid"
            case 'integer':
            case 'float':
            case 'decimal':
                if (!validateFormField(name, options)) {
                    data_valid = false;
    
                    data_errors[name] = ['{% trans "Enter a valid number" %}'];
                }
                break;
            default:
                break;
            }

            var value = getFormFieldValue(name, field, options);

            // Handle file inputs
            if (field.type == 'image upload' || field.type == 'file upload') {

                var field_el = $(options.modal).find(`#id_${name}`)[0];

                var field_files = field_el.files;

                if (field_files.length > 0) {
                    // One file per field, please!
                    var file = field_files[0];

                    form_data.append(name, file);
                    
                    has_files = true;
                }
            } else {

                // Normal field (not a file or image)
                form_data.append(name, value);

                data[name] = value;
            }
        } else {
            console.log(`WARNING: Could not find field matching '${name}'`);
        }
    }

    if (!data_valid) {
        handleFormErrors(data_errors, fields, options);
        return;
    }

    var upload_func = inventreePut;

    if (has_files) {
        upload_func = inventreeFormDataUpload;
        data = form_data;
    }

    // Optionally pre-process the data before uploading to the server
    if (options.processBeforeUpload) {
        data = options.processBeforeUpload(data);
    }

    // Submit data
    upload_func(
        options.url,
        data,
        {
            method: options.method,
            success: function(response) {
                handleFormSuccess(response, options);
            },
            error: function(xhr) {
                
                switch (xhr.status) {
                case 400:
                    handleFormErrors(xhr.responseJSON, fields, options);
                    break;
                default:
                    $(options.modal).modal('hide');

                    console.log(`upload error at ${options.url}`);
                    showApiError(xhr, options.url);
                    break;
                }
            }
        }
    );
}


/*
 * Update (set) the field values based on the specified data.
 *
 * Iterate through each of the displayed fields,
 * and set the 'val' attribute of each one.
 *
 */
function updateFieldValues(fields, options) {
  
    for (var idx = 0; idx < options.field_names.length; idx++) {

        var name = options.field_names[idx];

        var field = fields[name] || null;

        if (field == null) {
            continue;
        }

        var value = field.value;

        if (value == null) {
            value = field.default;
        }

        if (value == null) {
            continue;
        }

        updateFieldValue(name, value, field, options);
    }
}

/*
 * Update the value of a named field
 */
function updateFieldValue(name, value, field, options) {

    var el = getFormFieldElement(name, options);

    if (!el) {
        console.log(`WARNING: updateFieldValue could not find field '${name}'`);
        return;
    }

    switch (field.type) {
    case 'boolean':
        el.prop('checked', value);
        break;
    case 'related field':
        // Clear?
        if (value == null && !field.required) {
            el.val(null).trigger('change');
        }
        // TODO - Specify an actual value!
        break;
    case 'file upload':
    case 'image upload':
        break;
    default:
        el.val(value);
        break;
    }
}


// Find the named field element in the modal DOM
function getFormFieldElement(name, options) {

    var el = $(options.modal).find(`#id_${name}`);

    if (!el.exists) {
        console.log(`ERROR: Could not find form element for field '${name}'`);
    }

    return el;
}


/*
 * Check that a "numerical" input field has a valid number in it.
 * An invalid number is expunged at the client side by the getFormFieldValue() function,
 * which means that an empty string '' is sent to the server if the number is not valud.
 * This can result in confusing error messages displayed under the form field.
 * 
 * So, we can invalid numbers and display errors *before* the form is submitted!
 */
function validateFormField(name, options) {

    if (getFormFieldElement(name, options)) {

        var el = document.getElementById(`id_${name}`);

        if (el.validity.valueMissing) {
            // Accept empty strings (server will validate)
            return true;
        } else {
            return el.validity.valid;
        }
    } else {
        return false;
    }

}


/*
 * Extract and field value before sending back to the server
 *
 * arguments:
 * - name: The name of the field
 * - field: The field specification provided from the OPTIONS request
 * - options: The original options object provided by the client
 */
function getFormFieldValue(name, field, options) {

    // Find the HTML element
    var el = getFormFieldElement(name, options);

    if (!el) {
        return null;
    }

    var value = null;

    switch (field.type) {
    case 'boolean':
        value = el.is(':checked');
        break;
    case 'date':
    case 'datetime':
        value = el.val();

        // Ensure empty values are sent as nulls
        if (!value || value.length == 0) {
            value = null;
        }
        break;
    default:
        value = el.val();
        break;
    }

    return value;
}


/*
 * Handle successful form posting
 * 
 * arguments:
 * - response: The JSON response object from the server
 * - options: The original options object provided by the client
 */
function handleFormSuccess(response, options) {

    // Close the modal
    if (!options.preventClose) {
        // Note: The modal will be deleted automatically after closing
        $(options.modal).modal('hide');
    }

    // Display any required messages
    // Should we show alerts immediately or cache them?
    var cache = (options.follow && response.url) || options.redirect || options.reload;

    // Display any messages
    if (response && (response.success || options.successMessage)) {
        showAlertOrCache(response.success || options.successMessage, cache, {style: 'success'});
    }
    
    if (response && response.info) {
        showAlertOrCache(response.info, cache, {style: 'info'});
    }

    if (response && response.warning) {
        showAlertOrCache(response.warning, cache, {style: 'warning'});
    }

    if (response && response.danger) {
        showAlertOrCache(response.danger, cache, {style: 'danger'});
    }

    if (options.onSuccess) {
        // Callback function
        options.onSuccess(response, options);
    }

    if (options.follow && response.url) {
        // Follow the returned URL
        window.location.href = response.url;
    } else if (options.reload) {
        // Reload the current page
        location.reload();
    } else if (options.redirect) {
        // Redirect to a specified URL
        window.location.href = options.redirect;
    }
}



/*
 * Remove all error text items from the form
 */
function clearFormErrors(options) {

    // Remove the individual error messages
    $(options.modal).find('.form-error-message').remove();

    // Remove the "has error" class
    $(options.modal).find('.form-field-error').removeClass('form-field-error');

    // Hide the 'non field errors'
    $(options.modal).find('#non-field-errors').html('');
}

/*
 * Display form error messages as returned from the server,
 * specifically for errors returned in an array.
 *
 * We need to know the unique ID of each item in the array,
 * and the array length must equal the length of the array returned from the server
 * 
 * arguments:
 * - response: The JSON error response from the server
 * - parent: The name of the parent field e.g. "items"
 * - options: The global options struct
 * 
 * options:
 * - nested: A map of nested ID values for the "parent" field
 *           e.g.
 *           {
 *               "items": [
 *                  1,
 *                  2,
 *                  12
 *               ]
 *           }
 * 
 */

function handleNestedErrors(errors, field_name, options) {

    var error_list = errors[field_name];

    // Ignore null or empty list
    if (!error_list) {
        return;
    }

    var nest_list = nest_list = options['nested'][field_name];
    
    // Nest list must be provided!
    if (!nest_list) {
        console.log(`WARNING: handleNestedErrors missing nesting options for field '${fieldName}'`);
        return;
    }

    for (var idx = 0; idx < error_list.length; idx++) {
        
        var error_item = error_list[idx];
        
        if (idx >= nest_list.length) {
            console.log(`WARNING: handleNestedErrors returned greater number of errors (${error_list.length}) than could be handled (${nest_list.length})`);
            break;
        }

        // Extract the particular ID of the nested item
        var nest_id = nest_list[idx];
        
        // Here, error_item is a map of field names to error messages
        for (sub_field_name in error_item) {
            var errors = error_item[sub_field_name];

            // Find the target (nested) field
            var target = `${field_name}_${sub_field_name}_${nest_id}`;

            for (var ii = errors.length-1; ii >= 0; ii--) {

                var error_text = errors[ii];

                addFieldErrorMessage(target, error_text, ii, options);
            }
        }
    }
}



/*
 * Display form error messages as returned from the server.
 * 
 * arguments:
 * - errors: The JSON error response from the server
 * - fields: The form data object
 * - options: Form options provided by the client
 */
function handleFormErrors(errors, fields, options) {

    // Reset the status of the "submit" button
    $(options.modal).find('#modal-form-submit').prop('disabled', false);

    // Remove any existing error messages from the form
    clearFormErrors(options);

    var non_field_errors = $(options.modal).find('#non-field-errors');

    // TODO: Display the JSON error text when hovering over the "info" icon
    non_field_errors.append(
        `<div class='alert alert-block alert-danger'>
            <b>{% trans "Form errors exist" %}</b>
            <span id='form-errors-info' class='float-right fas fa-info-circle icon-red'>
            </span>
        </div>`
    );

    // Non-field errors?
    if ('non_field_errors' in errors) {

        var nfe = errors.non_field_errors;

        for (var idx = 0; idx < nfe.length; idx++) {
            var err = nfe[idx];

            var html = `
            <div class='alert alert-block alert-danger'>
                ${err}
            </div>`;

            non_field_errors.append(html);
        }
    }

    var first_error_field = null;

    for (var field_name in errors) {

        if (field_name in fields) {

            var field = fields[field_name];

            if ((field.type == 'field') && ('child' in field)) {
                // This is a "nested" field
                handleNestedErrors(errors, field_name, options);
            } else {
                // This is a "simple" field

                var field_errors = errors[field_name];

                if (field_errors && !first_error_field && isFieldVisible(field_name, options)) {
                    first_error_field = field_name;
                }

                // Add an entry for each returned error message
                for (var ii = field_errors.length-1; ii >= 0; ii--) {

                    var error_text = field_errors[ii];

                    addFieldErrorMessage(field_name, error_text, ii, options);
                }
            }
        }
    }

    if (first_error_field) {
        // Ensure that the field in question is visible
        document.querySelector(`#div_id_${field_name}`).scrollIntoView({
            behavior: 'smooth',
        });
    } else {
        // Scroll to the top of the form
        $(options.modal).find('.modal-form-content-wrapper').scrollTop(0);
    }

    $(options.modal).find('.modal-content').addClass('modal-error');
}


/*
 * Add a rendered error message to the provided field
 */
function addFieldErrorMessage(field_name, error_text, error_idx, options) {

    // Add the 'form-field-error' class
    $(options.modal).find(`#div_id_${field_name}`).addClass('form-field-error');

    var field_dom = $(options.modal).find(`#errors-${field_name}`);

    if (field_dom) {

        var error_html = `
        <span id='error_${error_idx}_id_${field_name}' class='help-block form-error-message'>
            <strong>${error_text}</strong>
        </span>`;

        field_dom.append(error_html);
    } else {
        console.log(`WARNING: addFieldErrorMessage could not locate field '${field_name}`);
    }
}


function isFieldVisible(field, options) {

    return $(options.modal).find(`#div_id_${field}`).is(':visible');
}


/*
 * Attach callbacks to specified fields,
 * triggered after the field value is edited.
 * 
 * Callback function is called with arguments (name, field, options)
 */
function addFieldCallbacks(fields, options) {

    for (var idx = 0; idx < options.field_names.length; idx++) {
        
        var name = options.field_names[idx];

        var field = fields[name];

        if (!field || !field.onEdit) continue;

        addFieldCallback(name, field, options);
    }
}


function addFieldCallback(name, field, options) {

    var el = getFormFieldElement(name, options);

    el.change(function() {

        var value = getFormFieldValue(name, field, options);

        field.onEdit(value, name, field, options);
    });
}


function addClearCallbacks(fields, options) {

    for (var idx = 0; idx < options.field_names.length; idx++) {

        var name = options.field_names[idx];

        var field = fields[name];

        if (!field || field.required) continue;

        addClearCallback(name, field, options);
    }
}


function addClearCallback(name, field, options) {

    var el = $(options.modal).find(`#clear_${name}`);
    
    if (!el) {
        console.log(`WARNING: addClearCallback could not find field '${name}'`);
        return;
    }

    el.click(function() {
        updateFieldValue(name, null, field, options);
    });
}


// Initialize callbacks and initial states for groups
function initializeGroups(fields, options) {

    var modal = options.modal;

    // Callback for when the group is expanded
    $(modal).find('.form-panel-content').on('show.bs.collapse', function() {

        var panel = $(this).closest('.form-panel');
        var group = panel.attr('group');

        var icon = $(modal).find(`#group-icon-${group}`);

        icon.removeClass('fa-angle-right');
        icon.addClass('fa-angle-up');
    });

    // Callback for when the group is collapsed
    $(modal).find('.form-panel-content').on('hide.bs.collapse', function() {

        var panel = $(this).closest('.form-panel');
        var group = panel.attr('group');

        var icon = $(modal).find(`#group-icon-${group}`);

        icon.removeClass('fa-angle-up');
        icon.addClass('fa-angle-right');
    });

    // Set initial state of each specified group
    for (var group in options.groups) {

        var group_options = options.groups[group];

        if (group_options.collapsed) {
            $(modal).find(`#form-panel-content-${group}`).collapse('hide');
        } else {
            $(modal).find(`#form-panel-content-${group}`).collapse('show');
        }

        if (group_options.hidden) {
            hideFormGroup(group, options);
        }
    }
}

// Set the placeholder value for a field
function setFormInputPlaceholder(name, placeholder, options) {
    $(options.modal).find(`#id_${name}`).attr('placeholder', placeholder);
}

// Clear a form input
function clearFormInput(name, options) {
    updateFieldValue(name, null, {}, options);
}

// Disable a form input
function disableFormInput(name, options) {
    $(options.modal).find(`#id_${name}`).prop('disabled', true);
}


// Enable a form input
function enableFormInput(name, options) {
    $(options.modal).find(`#id_${name}`).prop('disabled', false);
}


// Hide a form input
function hideFormInput(name, options) {
    $(options.modal).find(`#div_id_${name}`).hide();
}


// Show a form input
function showFormInput(name, options) {
    $(options.modal).find(`#div_id_${name}`).show();
}


// Hide a form group
function hideFormGroup(group, options) {
    $(options.modal).find(`#form-panel-${group}`).hide();
}

// Show a form group
function showFormGroup(group, options) {
    $(options.modal).find(`#form-panel-${group}`).show();
}

function setFormGroupVisibility(group, vis, options) {
    if (vis) {
        showFormGroup(group, options);
    } else {
        hideFormGroup(group, options);
    }
}


function initializeRelatedFields(fields, options) {

    var field_names = options.field_names;

    for (var idx = 0; idx < field_names.length; idx++) {

        var name = field_names[idx];

        var field = fields[name] || null;

        if (!field || field.hidden) continue;

        switch (field.type) {
        case 'related field':
            initializeRelatedField(field, fields, options);
            break;
        case 'choice':
            initializeChoiceField(field, fields, options);
            break;
        default:
            break;
        }
    }
}


/*
 * Add a button to launch a secondary modal, to create a new modal instance.
 *
 * arguments:
 * - name: The name of the field
 * - field: The field data object
 * - options: The options object provided by the client
 */
function addSecondaryModal(field, fields, options) {

    var name = field.name;

    var secondary = field.secondary;

    var html = `
    <span style='float: right;'>
        <div type='button' class='btn btn-primary btn-secondary btn-form-secondary' title='${secondary.title || secondary.label}' id='btn-new-${name}'>
            ${secondary.label || secondary.title}
        </div>
    </span>`;

    $(options.modal).find(`label[for="id_${name}"]`).append(html);

    // Callback function when the secondary button is pressed
    $(options.modal).find(`#btn-new-${name}`).click(function() {

        // Determine the API query URL
        var url = secondary.api_url || field.api_url;

        // If the "fields" attribute is a function, call it with data
        if (secondary.fields instanceof Function) {

            // Extract form values at time of button press
            var data = extractFormData(fields, options);

            secondary.fields = secondary.fields(data);
        }

        // If no onSuccess function is defined, provide a default one
        if (!secondary.onSuccess) {
            secondary.onSuccess = function(data) {

                // Force refresh from the API, to get full detail
                inventreeGet(`${url}${data.pk}/`, {}, {
                    success: function(responseData) {

                        setRelatedFieldData(name, responseData, options);
                    }
                });
            };
        }

        // Method should be "POST" for creation
        secondary.method = secondary.method || 'POST';

        constructForm(
            url,
            secondary
        );
    });
}


/*
 * Initialize a single related-field
 * 
 * argument:
 * - modal: DOM identifier for the modal window
 * - name: name of the field e.g. 'location'
 * - field: Field definition from the OPTIONS request
 * - options: Original options object provided by the client
 */
function initializeRelatedField(field, fields, options) {

    var name = field.name;

    if (!field.api_url) {
        // TODO: Provide manual api_url option?
        console.log(`WARNING: Related field '${name}' missing 'api_url' parameter.`);
        return;
    }

    // Find the select element and attach a select2 to it
    var select = getFormFieldElement(name, options);

    // Add a button to launch a 'secondary' modal
    if (field.secondary != null) {
        addSecondaryModal(field, fields, options);
    }

    // TODO: Add 'placeholder' support for entry select2 fields

    // limit size for AJAX requests
    var pageSize = options.pageSize || 25;

    select.select2({
        placeholder: '',
        dropdownParent: $(options.modal),
        dropdownAutoWidth: false,
        language: {
            noResults: function(query) {
                if (field.noResults) {
                    return field.noResults(query);
                } else {
                    return '{% trans "No results found" %}';
                }
            }
        },
        ajax: {
            url: field.api_url,
            dataType: 'json',
            delay: 250,
            cache: true,
            data: function(params) {

                var offset = 0;

                if (!params.page) {
                    offset = 0;
                } else {
                    offset = (params.page - 1) * pageSize;
                }

                // Custom query filters can be specified against each field
                var query = field.filters || {};

                // Add search and pagination options
                query.search = params.term;
                query.offset = offset;
                query.limit = pageSize;
                
                // Allow custom run-time filter augmentation
                if ('adjustFilters' in field) {
                    query = field.adjustFilters(query, options);
                }

                return query;
            },
            processResults: function(response) {
                // Convert the returned InvenTree data into select2-friendly format

                var data = [];

                var more = false;

                if ('count' in response && 'results' in response) {
                    // Response is paginated
                    data = response.results;

                    // Any more data available?
                    if (response.next) {
                        more = true;
                    }

                } else {
                    // Non-paginated response
                    data = response;
                }

                // Each 'row' must have the 'id' attribute
                for (var idx = 0; idx < data.length; idx++) {
                    data[idx].id = data[idx].pk;
                }

                // Ref: https://select2.org/data-sources/formats
                var results = {
                    results: data,
                    pagination: {
                        more: more,
                    }
                };

                return results;
            },
        },
        templateResult: function(item) {

            // Extract 'instance' data passed through from an initial value
            // Or, use the raw 'item' data as a backup
            var data = item;
            
            if (item.element && item.element.instance) {
                data = item.element.instance;
            }

            if (!data.pk) {
                return $(searching());
            }

            // Custom formatting for the search results
            if (field.model) {
                // If the 'model' is specified, hand it off to the custom model render
                var html = renderModelData(name, field.model, data, field, options);
                return $(html);
            } else {
                // Return a simple renderering
                console.log(`WARNING: templateResult() missing 'field.model' for '${name}'`);
                return `${name} - ${item.id}`;
            }
        },
        templateSelection: function(item) {

            // Extract 'instance' data passed through from an initial value
            // Or, use the raw 'item' data as a backup
            var data = item;
            
            if (item.element && item.element.instance) {
                data = item.element.instance;
            }

            // Run optional callback function
            if (field.onSelect && data) {
                field.onSelect(data, field, options);
            }

            if (!data.pk) {
                return field.placeholder || '';
            }

            // Custom formatting for selected item
            if (field.model) {
                // If the 'model' is specified, hand it off to the custom model render
                var html = renderModelData(name, field.model, data, field, options);
                return $(html);
            } else {
                // Return a simple renderering
                console.log(`WARNING: templateSelection() missing 'field.model' for '${name}'`);
                return `${name} - ${item.id}`;
            }
        }
    });

    // If a 'value' is already defined, grab the model info from the server
    if (field.value) {
        
        var pk = field.value;
        var url = `${field.api_url}/${pk}/`.replace('//', '/');

        inventreeGet(url, field.filters || {}, {
            success: function(data) {
                setRelatedFieldData(name, data, options);
            }
        });
    } else if (field.auto_fill) {
        // Attempt to auto-fill the field

        var filters = field.filters || {};

        // Enforce pagination, limit to a single return (for fast query)
        filters.limit = 1;
        filters.offset = 0;

        inventreeGet(field.api_url, field.filters || {}, {
            success: function(data) {

                // Only a single result is available, given the provided filters
                if (data.count == 1) {
                    setRelatedFieldData(name, data.results[0], options);
                }
            }
        });
    }
}


/*
 * Set the value of a select2 instace for a "related field",
 * e.g. with data returned from a secondary modal
 * 
 * arguments:
 * - name: The name of the field
 * - data: JSON data representing the model instance
 * - options: The modal form specifications
 */
function setRelatedFieldData(name, data, options) {

    var select = getFormFieldElement(name, options);

    var option = new Option(name, data.pk, true, true);

    // Assign the JSON data to the 'instance' attribute,
    // so we can access and render it later
    option.instance = data;

    select.append(option).trigger('change');

    select.trigger({
        type: 'select2:select',
        params: {
            data: data
        }
    });
}


function initializeChoiceField(field, fields, options) {

    var select = getFormFieldElement(field.name, options);

    select.select2({
        dropdownAutoWidth: false,
        dropdownParent: $(options.modal),
    });
}


// Render a 'no results' element
function searching() {
    return `<span>{% trans "Searching" %}...</span>`;
}

/*
 * Render a "foreign key" model reference in a select2 instance.
 * Allows custom rendering with access to the entire serialized object.
 * 
 * arguments:
 * - name: The name of the field e.g. 'location'
 * - model: The name of the InvenTree model e.g. 'stockitem'
 * - data: The JSON data representation of the modal instance (GET request)
 * - parameters: The field definition (OPTIONS) request
 * - options: Other options provided at time of modal creation by the client
 */
function renderModelData(name, model, data, parameters, options) {

    if (!data) {
        return parameters.placeholder || '';
    }

    // TODO: Implement this function for various models

    var html = null;

    var renderer = null;

    // Find a custom renderer 
    switch (model) {
    case 'company':
        renderer = renderCompany;
        break;
    case 'stockitem':
        renderer = renderStockItem;
        break;
    case 'stocklocation':
        renderer = renderStockLocation;
        break;
    case 'part':
        renderer = renderPart;
        break;
    case 'partcategory':
        renderer = renderPartCategory;
        break;
    case 'partparametertemplate':
        renderer = renderPartParameterTemplate;
        break;
    case 'salesorder':
        renderer = renderSalesOrder;
        break;
    case 'salesordershipment':
        renderer = renderSalesOrderShipment;
        break;
    case 'manufacturerpart':
        renderer = renderManufacturerPart;
        break;
    case 'supplierpart':
        renderer = renderSupplierPart;
        break;
    case 'build':
        renderer = renderBuild;
        break;
    case 'owner':
        renderer = renderOwner;
        break;
    case 'user':
        renderer = renderUser;
        break;
    default:
        break;
    }
    
    if (renderer != null) {
        html = renderer(name, data, parameters, options);
    }

    if (html != null) {
        return html;
    } else {
        console.log(`ERROR: Rendering not implemented for model '${model}'`);
        // Simple text rendering
        return `${model} - ID ${data.id}`;
    }
}


/*
 * Construct a single form 'field' for rendering in a form.
 * 
 * arguments:
 * - name: The 'name' of the field
 * - parameters: The field parameters supplied by the DRF OPTIONS method
 * 
 * options:
 * - 
 * 
 * The function constructs a fieldset which mostly replicates django "crispy" forms:
 * 
 * - Field name
 * - Field <input> (depends on specified field type)
 * - Field description (help text)
 * - Field errors
 */
function constructField(name, parameters, options) {

    var html = '';

    // Shortcut for simple visual fields
    if (parameters.type == 'candy') {
        return constructCandyInput(name, parameters, options);
    }

    var field_name = `id_${name}`;

    // Hidden inputs are rendered without label / help text / etc
    if (parameters.hidden) {
        return constructHiddenInput(name, parameters, options);
    }

    // Are we ending a group?
    if (options.current_group && parameters.group != options.current_group) {
        html += `</div></div>`;

        // Null out the current "group" so we can start a new one
        options.current_group = null;
    }

    // Are we starting a new group?
    if (parameters.group) {

        var group = parameters.group;

        var group_options = options.groups[group] || {};

        // Are we starting a new group?
        // Add HTML for the start of a separate panel
        if (parameters.group != options.current_group) {

            html += `
            <div class='panel form-panel' id='form-panel-${group}' group='${group}'>
                <div class='panel-heading form-panel-heading' id='form-panel-heading-${group}'>`;
            if (group_options.collapsible) {
                html += `
                <div data-bs-toggle='collapse' data-bs-target='#form-panel-content-${group}'>
                    <a href='#'><span id='group-icon-${group}' class='fas fa-angle-up'></span> 
                `;
            } else {
                html += `<div>`;
            }

            html += `<h4 style='display: inline;'>${group_options.title || group}</h4>`;

            if (group_options.collapsible) {
                html += `</a>`;
            }

            html += `
                </div></div>
                <div class='panel-content form-panel-content' id='form-panel-content-${group}'>
            `;
        }

        // Keep track of the group we are in
        options.current_group = group;
    }

    var form_classes = 'form-group';

    if (parameters.errors) {
        form_classes += ' form-field-error';
    }
    
    // Optional content to render before the field
    if (parameters.before) {
        html += parameters.before;
    }
    
    html += `<div id='div_${field_name}' class='${form_classes}'>`;

    // Add a label
    if (!options.hideLabels) {
        html += constructLabel(name, parameters);
    }

    html += `<div class='controls'>`;

    // Does this input deserve "extra" decorators?
    var extra = parameters.prefix != null;
    
    // Some fields can have 'clear' inputs associated with them
    if (!parameters.required && !parameters.read_only) {
        switch (parameters.type) {
        case 'string':
        case 'url':
        case 'email':
        case 'integer':
        case 'float':
        case 'decimal':
        case 'related field':
        case 'date':
            extra = true;
            break;
        default:
            break;
        }
    }
    
    if (extra) {
        html += `<div class='input-group'>`;
    
        if (parameters.prefix) {
            html += `<span class='input-group-text'>${parameters.prefix}</span>`;
        }
    }

    html += constructInput(name, parameters, options);

    if (extra) {

        if (!parameters.required) {
            html += `
            <span class='input-group-text form-clear' id='clear_${name}' title='{% trans "Clear input" %}'>
                <span class='icon-red fas fa-backspace'></span>
            </span>`;
        }

        html += `</div>`; // input-group
    }

    if (parameters.help_text && !options.hideLabels) {

        // Boolean values are handled differently!
        if (parameters.type != 'boolean') {
            html += constructHelpText(name, parameters, options);
        }
    }

    // Div for error messages
    html += `<div id='errors-${name}'></div>`;


    html += `</div>`; // controls
    html += `</div>`; // form-group
    
    if (parameters.after) {
        html += parameters.after;
    }

    return html;
}


/*
 * Construct a 'label' div
 *
 * arguments:
 * - name: The name of the field
 * - required: Is this a required field?
 */
function constructLabel(name, parameters) {

    var label_classes = 'control-label';

    if (parameters.required) {
        label_classes += ' requiredField';
    }

    var html = `<label class='${label_classes}' for='id_${name}'>`;
    
    if (parameters.label) {
        html += `${parameters.label}`;
    } else {
        html += `${name}`;
    }
    
    if (parameters.required) {
        html += `<span class='asteriskField'>*</span>`;
    }
    
    html += `</label>`;

    return html;
}


/*
 * Construct a form input based on the field parameters
 * 
 * arguments:
 * - name: The name of the field
 * - parameters: Field parameters returned by the OPTIONS method
 * 
 */
function constructInput(name, parameters, options) {

    var html = '';

    var func = null;

    switch (parameters.type) {
    case 'boolean':
        func = constructCheckboxInput;
        break;
    case 'string':
    case 'url':
    case 'email':
        func = constructTextInput;
        break;
    case 'integer':
    case 'float':
    case 'decimal':
        func = constructNumberInput;
        break;
    case 'choice':
        func = constructChoiceInput;
        break;
    case 'related field':
        func = constructRelatedFieldInput;
        break;
    case 'image upload':
    case 'file upload':
        func = constructFileUploadInput;
        break;
    case 'date':
        func = constructDateInput;
        break;
    case 'candy':
        func = constructCandyInput;
        break;
    case 'raw':
        func = constructRawInput;
    default:
        // Unsupported field type!
        break;
    }
        
    if (func != null) {
        html = func(name, parameters, options);
    } else {
        console.log(`WARNING: Unhandled form field type: '${parameters.type}'`);
    }

    return html;
}


// Construct a set of default input options which apply to all input types
function constructInputOptions(name, classes, type, parameters) {

    var opts = [];

    opts.push(`id='id_${name}'`);

    opts.push(`class='${classes}'`);

    opts.push(`name='${name}'`);

    opts.push(`type='${type}'`);

    // Read only?
    if (parameters.read_only) {
        opts.push(`readonly=''`);
    }

    if (parameters.value != null) {
        // Existing value?
        opts.push(`value='${parameters.value}'`);
    } else if (parameters.default != null) {
        // Otherwise, a defualt value?
        opts.push(`value='${parameters.default}'`);
    }

    // Maximum input length
    if (parameters.max_length != null) {
        opts.push(`maxlength='${parameters.max_length}'`);
    }

    // Minimum input length
    if (parameters.min_length != null) {
        opts.push(`minlength='${parameters.min_length}'`);
    }

    // Maximum value
    if (parameters.max_value != null) {
        opts.push(`max='${parameters.max_value}'`);
    }

    // Minimum value
    if (parameters.min_value != null) {
        opts.push(`min='${parameters.min_value}'`);
    }

    // Field is required?
    if (parameters.required) {
        opts.push(`required=''`);
    }

    // Custom mouseover title?
    if (parameters.title != null) {
        opts.push(`title='${parameters.title}'`);
    }

    // Placeholder?
    if (parameters.placeholder != null) {
        opts.push(`placeholder='${parameters.placeholder}'`);
    }

    switch (parameters.type) {
    case 'boolean':
        break;
    case 'integer':
    case 'float':
    case 'decimal':
        opts.push(`step='any'`);
        break;
    default:
        break;
    }

    if (parameters.multiline) {
        return `<textarea ${opts.join(' ')}></textarea>`;
    } else if (parameters.type == 'boolean') {
        return `
        <div class='form-check form-switch'>
            <input ${opts.join(' ')}>
            <label class='form-check-label' for=''>
                <em><small>${parameters.help_text}</small></em>
            </label>
        </div>
        `;
    } else {
        return `<input ${opts.join(' ')}>`;
    }
}


// Construct a "hidden" input
function constructHiddenInput(name, parameters) {

    return constructInputOptions(
        name,
        'hiddeninput',
        'hidden',
        parameters
    );
}


// Construct a "checkbox" input
function constructCheckboxInput(name, parameters) {

    return constructInputOptions(
        name,
        'form-check-input',
        'checkbox',
        parameters
    );
}


// Construct a "text" input
function constructTextInput(name, parameters) {

    var classes = '';
    var type = '';

    switch (parameters.type) {
    default:
        classes = 'textinput textInput form-control';
        type = 'text';
        break;
    case 'url':
        classes = 'urlinput form-control';
        type = 'url';
        break;
    case 'email':
        classes = 'emailinput form-control';
        type = 'email';
        break;
    }

    return constructInputOptions(
        name,
        classes,
        type,
        parameters
    );
}


// Construct a "number" field
function constructNumberInput(name, parameters) {

    return constructInputOptions(
        name,
        'numberinput form-control',
        'number',
        parameters
    );
}


// Construct a "choice" input
function constructChoiceInput(name, parameters) {

    var html = `<select id='id_${name}' class='select form-control' name='${name}'>`;

    var choices = parameters.choices || [];

    for (var idx = 0; idx < choices.length; idx++) {

        var choice = choices[idx];

        var selected = '';

        if (parameters.value && parameters.value == choice.value) {
            selected = ` selected=''`;
        }

        html += `<option value='${choice.value}'${selected}>`;
        html += `${choice.display_name}`;
        html += `</option>`;
    }

    html += `</select>`;

    return html;
}


/*
 * Construct a "related field" input.
 * This will create a "select" input which will then, (after form is loaded),
 * be converted into a select2 input.
 * This will then be served custom data from the API (as required)...
 */
function constructRelatedFieldInput(name) {

    var html = `<select id='id_${name}' class='select form-control' name='${name}'></select>`;

    // Don't load any options - they will be filled via an AJAX request

    return html;
}


/*
 * Construct a field for file upload
 */
function constructFileUploadInput(name, parameters) {

    var cls = 'clearablefileinput';

    if (parameters.required) {
        cls = 'fileinput';
    }

    return constructInputOptions(
        name,
        cls,
        'file',
        parameters
    );
}


/*
 * Construct a field for a date input
 */
function constructDateInput(name, parameters) {

    return constructInputOptions(
        name,
        'dateinput form-control',
        'date',
        parameters
    );
}


/*
 * Construct a "candy" field input
 * No actual field data!
 */
function constructCandyInput(name, parameters) {

    return parameters.html;

}


/*
 * Construct a "raw" field input
 * No actual field data!
 */
function constructRawInput(name, parameters) {

    return parameters.html;

}


/*
 * Construct a 'help text' div based on the field parameters
 * 
 * arguments:
 * - name: The name of the field
 * - parameters: Field parameters returned by the OPTIONS method
 *  
 */
function constructHelpText(name, parameters) {
    
    var html = `<div id='hint_id_${name}' class='help-block'><i>${parameters.help_text}</i></div>`;

    return html;
}
