<template>
    <div
        v-bind="$attrs"
        class="formulario-input"
    >
        <slot :context="context" />
    </div>
</template>

<script lang="ts">
import Vue from 'vue'
import {
    Component,
    Inject,
    Model,
    Prop,
    Watch,
} from 'vue-property-decorator'
import { arrayify, has, shallowEqualObjects, snakeToCamel } from './utils'
import {
    processConstraints,
    validate,
    ValidationRuleFn,
    ValidationMessageI18NFn,
    Violation,
} from '@/validation/validator'

const VALIDATION_BEHAVIOR = {
    DEMAND: 'demand',
    LIVE: 'live',
    SUBMIT: 'submit',
}

type Context<U> = {
    model: U;
    name: string;
    runValidation(): Promise<Violation[]>;
    violations: Violation[];
    errors: string[];
    allErrors: string[];
}

interface ModelGetConverter {
    <U, T>(value: U|Empty): U|T|Empty;
}

interface ModelSetConverter {
    <T, U>(curr: U|T, prev: U|Empty): U|T;
}

type Empty = null | undefined

@Component({ name: 'FormularioField', inheritAttrs: false })
export default class FormularioField extends Vue {
    @Inject({ default: undefined }) formularioSetter!: Function|undefined
    @Inject({ default: () => (): void => {} }) onFormularioFieldValidation!: Function
    @Inject({ default: undefined }) formularioRegister!: Function|undefined
    @Inject({ default: undefined }) formularioDeregister!: Function|undefined
    @Inject({ default: () => (): Record<string, any> => ({}) }) getFormValues!: Function
    @Inject({ default: undefined }) addErrorObserver!: Function|undefined
    @Inject({ default: undefined }) removeErrorObserver!: Function|undefined
    @Inject({ default: '' }) path!: string

    @Model('input', { default: '' }) value!: any

    @Prop({
        required: true,
        validator: (name: any): boolean => typeof name === 'string' && name.length > 0,
    }) name!: string

    @Prop({ default: '' }) validation!: string|any[]
    @Prop({ default: () => ({}) }) validationRules!: Record<string, ValidationRuleFn>
    @Prop({ default: () => ({}) }) validationMessages!: Record<string, ValidationMessageI18NFn|string>
    @Prop({
        default: VALIDATION_BEHAVIOR.DEMAND,
        validator: behavior => Object.values(VALIDATION_BEHAVIOR).includes(behavior)
    }) validationBehavior!: string

    // Affects only observing & setting of local errors
    @Prop({ default: false }) errorsDisabled!: boolean

    @Prop({ default: () => <U, T>(value: U|Empty): U|T|Empty => value }) modelGetConverter!: ModelGetConverter
    @Prop({ default: () => <T, U>(value: U|T): U|T => value }) modelSetConverter!: ModelSetConverter

    public proxy: any = this.getInitialValue()

    private localErrors: string[] = []
    private violations: Violation[] = []
    private validationRun: Promise<any> = Promise.resolve()

    get fullQualifiedName (): string {
        return this.path !== '' ? `${this.path}.${this.name}` : this.name
    }

    get model (): any {
        const model = this.hasModel ? 'value' : 'proxy'
        return this.modelGetConverter(this[model])
    }

    set model (value: any) {
        value = this.modelSetConverter(value, this.proxy)

        if (!shallowEqualObjects(value, this.proxy)) {
            this.proxy = value
        }

        this.$emit('input', value)

        if (typeof this.formularioSetter === 'function') {
            this.formularioSetter(this.context.name, value)
        }
    }

    get context (): Context<any> {
        return Object.defineProperty({
            name: this.fullQualifiedName,
            runValidation: this.runValidation.bind(this),
            violations: this.violations,
            errors: this.localErrors,
            allErrors: [...this.localErrors, ...this.violations.map(v => v.message)],
        }, 'model', {
            get: () => this.model,
            set: (value: any) => {
                this.model = value
            },
        })
    }

    get normalizedValidationRules (): Record<string, ValidationRuleFn> {
        const rules: Record<string, ValidationRuleFn> = {}
        Object.keys(this.validationRules).forEach(key => {
            rules[snakeToCamel(key)] = this.validationRules[key]
        })
        return rules
    }

    get normalizedValidationMessages (): Record<string, ValidationMessageI18NFn|string> {
        const messages: Record<string, ValidationMessageI18NFn|string> = {}
        Object.keys(this.validationMessages).forEach(key => {
            messages[snakeToCamel(key)] = this.validationMessages[key]
        })
        return messages
    }

    /**
     * Determines if this formulario element is v-modeled or not.
     */
    get hasModel (): boolean {
        return has(this.$options.propsData || {}, 'value')
    }

    @Watch('proxy')
    onProxyChanged (newValue: any, oldValue: any): void {
        if (!this.hasModel && !shallowEqualObjects(newValue, oldValue)) {
            this.context.model = newValue
        }
        if (this.validationBehavior === VALIDATION_BEHAVIOR.LIVE) {
            this.runValidation()
        } else {
            this.violations = []
        }
    }

    @Watch('value')
    onValueChanged (newValue: any, oldValue: any): void {
        if (this.hasModel && !shallowEqualObjects(newValue, oldValue)) {
            this.context.model = newValue
        }
    }

    created (): void {
        this.initProxy()
        if (typeof this.formularioRegister === 'function') {
            this.formularioRegister(this.fullQualifiedName, this)
        }
        if (typeof this.addErrorObserver === 'function' && !this.errorsDisabled) {
            this.addErrorObserver({ callback: this.setErrors, type: 'input', field: this.fullQualifiedName })
        }
        if (this.validationBehavior === VALIDATION_BEHAVIOR.LIVE) {
            this.runValidation()
        }
    }

    // noinspection JSUnusedGlobalSymbols
    beforeDestroy (): void {
        if (!this.errorsDisabled && typeof this.removeErrorObserver === 'function') {
            this.removeErrorObserver(this.setErrors)
        }
        if (typeof this.formularioDeregister === 'function') {
            this.formularioDeregister(this.fullQualifiedName)
        }
    }

    getInitialValue (): any {
        return has(this.$options.propsData || {}, 'value') ? this.value : ''
    }

    initProxy (): void {
        // This should only be run immediately on created and ensures that the
        // proxy and the model are both the same before any additional registration.
        if (!shallowEqualObjects(this.context.model, this.proxy)) {
            this.context.model = this.proxy
        }
    }

    runValidation (): Promise<void> {
        this.validationRun = this.validate().then(violations => {
            const validationChanged = !shallowEqualObjects(violations, this.violations)
            this.violations = violations
            if (validationChanged) {
                const payload = {
                    name: this.context.name,
                    violations: this.violations,
                }
                this.$emit('validation', payload)
                if (typeof this.onFormularioFieldValidation === 'function') {
                    this.onFormularioFieldValidation(payload)
                }
            }

            return this.violations
        })
        return this.validationRun
    }

    validate (): Promise<Violation[]> {
        return validate(processConstraints(
            this.validation,
            this.$formulario.getRules(this.normalizedValidationRules),
            this.$formulario.getMessages(this, this.normalizedValidationMessages),
        ), {
            value: this.context.model,
            name: this.context.name,
            formValues: this.getFormValues(),
        })
    }

    hasValidationErrors (): Promise<boolean> {
        return new Promise(resolve => {
            this.$nextTick(() => {
                this.validationRun.then(() => resolve(this.violations.length > 0))
            })
        })
    }

    setErrors (errors: string[]): void {
        this.localErrors = arrayify(errors)
    }

    resetValidation (): void {
        this.localErrors = []
        this.violations = []
    }
}
</script>