<template>
    <div :class="bem()" @focusin="onFocusIn" @focusout="onFocusOut">
        <slot name="default" :validator="validatorData"></slot>
        <slot name="errors">
            <div v-if="!hideErrors" :class="[bem('error-container'), errorContainerClass]">
                <span
                    v-for="error in validatorData.visibleErrors"
                    :key="error.id"
                    :class="[bem('error-item'), errorItemClass]"
                >
                    <px-icon :class="bem('error-icon')" :src="iconInvalid" />
                    {{ error.message }}
                </span>
            </div>
        </slot>
    </div>
</template>

<script>
import PxBase from '../bases/PxBase.vue';
import PxBaseUniqueId from '../bases/PxBaseUniqueId.vue';

import PxIcon from './PxIcon.vue';
import iconInvalid from '../assets/feather/alert-triangle.svg?url';

const VALIDATORS = {
    required(val) {
        const isValid = val !== null && val !== undefined && val !== '';
        if (!isValid) {
            return 'Required. Must provide a value.';
        }
        return null;
    },
    oneof(val, allowedValues) {
        const isValid = allowedValues.includes(val);
        if (!isValid) {
            return 'Must match one of the allowed values.';
        }
        return null;
    },
    not(val, notValue) {
        const isValid = val !== notValue;
        if (!isValid) {
            return 'Invalid. Must provide a different value.';
        }
    },
    minlength(val, length) {
        const isValid = (val + '').length >= length;
        if (!isValid) {
            return `Must be at least ${length} characters.`;
        }
        return null;
    },
    maxlength(val, length) {
        const isValid = (val + '').length <= length;
        if (!isValid) {
            return `Must not exceed ${length} characters.`;
        }
        return null;
    },
    email(val) {
        const isValid = /^\w+([.-]?\w+)+@\w+([.:]?\w+)+(\.[a-zA-Z0-9]{2,3})+$/.test(val);
        if (!isValid) {
            return 'Must be a valid email address.';
        }
        return null;
    },
    phone(val) {
        // us phone numbers only, based on comment in this post:
        // https://stackoverflow.com/questions/123559/how-to-validate-phone-numbers-using-regex
        const isValid =
            /(?:(?:(\s*\(?([2-9]1[02-9]|[2-9][02-8]1|[2-9][02-8][02-9])\s*)|([2-9]1[02-9]|[2-9][02-8]1|[2-9][02-8][02-9]))\)?\s*(?:[.-]\s*)?)([2-9]1[02-9]|[2-9][02-9]1|[2-9][02-9]{2})\s*(?:[.-]\s*)?([0-9]{4})/.test(
                val,
            );
        if (!isValid) {
            return 'Must be a valid phone number.';
        }
        return null;
    },

    regex(val, regexStr) {
        const isValid = new RegExp(regexStr).test(val);
        if (!isValid) {
            return 'Must match the specified format.';
        }
        return null;
    },

    // url(val) {
    //     // TODO
    // },
    // number(val) {
    //     // TODO
    // },
    // integer(val) {
    //     // TODO
    // },
    // maxvalue(val, max) {
    //     // TODO
    // },
    // minvalue(val, min) {
    //     // TODO
    // },
};

export default {
    name: 'px-validator',
    components: { PxIcon },
    mixins: [PxBase, PxBaseUniqueId],
    props: {
        // the value to be validated
        value: { default: null },

        // list of validator rules, see explanation of syntax in notes above
        rules: { default: null },

        // Whether validation is currently active, possible values are:
        //  - true / false (obvious)
        //  - focus-in (becomes active once a child element has been focused)
        //  - focus-out (becomes active once a child element has lost focus)
        //  - focus-active (active only while a child element has focus)
        active: { default: 'focus-out' },

        // message overrides, can be a string (always used), object (overrides based on error id),
        // or a function (rule object provided as parameter)
        messageOverride: { default: null },

        // optional field id that can be set to disambiguate valid-changed events
        fieldId: { type: String, default: null },

        // if true, the default errors slot will be hidden altogether
        hideErrors: { type: Boolean, default: false },

        // if true, the default messages slot will show all violation messages instead of just the first
        allErrors: { type: Boolean, default: false },

        // class that will be applied to the default errors container and item respectively
        errorContainerClass: { type: String, default: null },
        errorItemClass: { type: String, default: null },

        // override class names
        inactiveClass: { type: String, default: null },
        validClass: { type: String, default: 'valid' },
        invalidClass: { type: String, default: 'invalid' },
        requiredClass: { type: String, default: 'required' },
    },
    data() {
        return { focusActive: false, focusIn: false, focusOut: false, iconInvalid };
    },
    computed: {
        fieldIdInternal() {
            return this.fieldId || this.uniqueId();
        },

        activeInternal() {
            if (typeof this.active == 'string') {
                if (this.active == 'focus-in') {
                    return this.focusIn;
                }
                if (this.active == 'focus-out') {
                    return this.focusOut;
                }
                if (this.active == 'focus-active') {
                    return this.focusActive;
                }
            }
            return !!this.active;
        },

        rulesArray() {
            let r = [];

            if (this.rules) {
                let t = typeof this.rules;

                // String

                // Expects a string like "rule1:param rule2 rule3" where rule ids and parameters are
                // separated by colons. This format works for most rules but won't work for
                // complicated parameters like those required for regex, oneof or custom callbacks.
                // For those, use the object format.
                if (t == 'string') {
                    const split = this.rules.split(/\s+/);
                    split.forEach(s => {
                        s = s.split(':');
                        const id = s[0];
                        const validator = id in VALIDATORS ? VALIDATORS[id] : null;
                        const param = s.length > 1 ? s[1] : null;
                        if (validator) {
                            r.push({
                                id,
                                validator,
                                param,
                            });
                        }
                    });
                }

                // Object
                // Expects an object like {required: true, regex: '...', myvalidator:(value) => {...} }
                // Use this format for more complex or customized validation rules.
                else if (t == 'object') {
                    for (let id in this.rules) {
                        // custom validator
                        if (typeof this.rules[id] == 'function') {
                            r.push({
                                id,
                                validator: this.rules[id],
                            });
                        }

                        // built in validator
                        else {
                            const validator = id in VALIDATORS ? VALIDATORS[id] : null;
                            if (validator) {
                                r.push({
                                    id,
                                    validator,
                                    param: this.rules[id],
                                });
                            }
                        }
                    }
                }
            }

            return r;
        },

        validatorData() {
            let errors = this.getErrors();

            let visibleErrors = [];
            if (this.activeInternal) {
                visibleErrors = !this.allErrors && errors?.length > 0 ? [errors[0]] : errors;
            }

            const isValid = errors.length == 0;
            const isRequired = this.rulesArray.find(r => r.id == 'required');
            const validityClass = isValid ? this.validClass : this.invalidClass;

            let v = {
                // this will be true unless a violation is found, even if validation is not active
                isValid: isValid,

                // whether valdition is presently active
                isActive: this.activeInternal,

                // field id
                fieldId: this.fieldIdInternal,

                // a class which can be applied to content to style it as valid, invalid (validation not active)
                class: this.activeInternal ? validityClass : this.inactiveClass,

                // a class for each of the rule violations in the form of invalid--[id]
                violationClasses: isValid ? [] : errors.map(e => this.invalidClass + '--' + e.id),

                // a class which can be applied to content to style required fields
                requiredClass: isRequired ? this.requiredClass : null,

                // whether the field is required (base on the special presence of the required rule)
                required: isRequired,

                errors: errors,
                visibleErrors: visibleErrors,
            };

            return v;
        },

        isValid() {
            return this.validatorData.isValid;
        },
    },
    watch: {
        isValid: {
            immediate: true,
            handler() {
                this.$emit('valid-changed', this.validatorData);
            },
        },
    },
    methods: {
        onFocusIn() {
            this.focusIn = true;
            this.focusActive = true;
        },
        onFocusOut() {
            this.focusOut = true;
            this.focusActive = false;
        },
        getErrorMessage(validator, defaultErrorMessage) {
            if (this.messageOverride) {
                if (typeof this.messageOverride == 'string') {
                    return this.messageOverride;
                }

                if (
                    typeof this.messageOverride == 'object' &&
                    validator.id in this.messageOverride
                ) {
                    return this.messageOverride[validator.id];
                }

                if (typeof this.messageOverride == 'function') {
                    return this.messageOverride(validator, defaultErrorMessage);
                }
            }

            return defaultErrorMessage;
        },
        getErrors() {
            let errors = [];

            this.rulesArray.forEach(r => {
                let error = r.validator(this.value, r.param);
                if (error) {
                    errors.push({ id: r.id, message: this.getErrorMessage(r, error) });
                }
            });

            return errors;
        },
    },
};
</script>

<style lang="scss">
@use '../styles/px.scss' as *;

.px-validator {
    display: flex;
    flex-direction: column;
    justify-content: stretch;

    $error-color: get('colors.error', #fc433e);

    &__error-container {
        display: flex;
        flex-direction: column;
        font-size: fs(xs);
        color: $error-color;
        min-height: 1.5em;
        line-height: 1.1;
        padding-top: 0.33em;
    }

    &__error-item {
        display: flex;
        align-items: center;
    }

    &__error-icon {
        margin-right: 0.3em;
    }
}
</style>
