/* eslint-disable no-case-declarations */
import * as React from 'react';

import {mapObject, set, isEqual} from './utilities';
import {
  FieldDescriptors,
  FieldState,
  ValueMapper,
  FieldStates,
  ValidationFunction,
} from './types';
import {List, Nested} from './components';

export interface RemoteError {
  field?: string[] | null;
  message: string;
}

type MaybeArray<T> = T | T[];
type MaybePromise<T> = T | Promise<T>;

interface SubmitHandler<Fields> {
  (formDetails: FormData<Fields>):
    | MaybePromise<RemoteError[]>
    | MaybePromise<void>;
}

export type Validator<T, F> = MaybeArray<ValidationFunction<T, F>>;

export type ValidatorDictionary<FieldMap> = {
  [FieldPath in keyof FieldMap]: Validator<FieldMap[FieldPath], FieldMap>
};

export interface FormData<Fields> {
  fields: FieldDescriptors<Fields>;
  dirty: boolean;
  valid: boolean;
  errors: RemoteError[];
}

export interface FormDetails<Fields> extends FormData<Fields> {
  submitting: boolean;
  reset(): void;
  submit(): void;
}

interface Props<Fields> {
  initialValues: Fields;
  children(form: FormDetails<Fields>): React.ReactNode;
  validators?: Partial<ValidatorDictionary<Fields>>;
  onSubmit?: SubmitHandler<Fields>;
  validateOnSubmit?: boolean;
  onInitialValuesChange?: 'reset-all' | 'reset-where-changed' | 'ignore';
  externalErrors?: RemoteError[];
}

interface State<Fields> {
  submitting: boolean;
  fields: FieldStates<Fields>;
  dirtyFields: (keyof Fields)[];
  errors: RemoteError[];
  externalErrors: RemoteError[];
}

export default class FormState<
  Fields extends Object
> extends React.PureComponent<Props<Fields>, State<Fields>> {
  static List = List;
  static Nested = Nested;

  static getDerivedStateFromProps<T>(newProps: Props<T>, oldState: State<T>) {
    const {
      initialValues,
      onInitialValuesChange,
      externalErrors = [],
    } = newProps;

    const externalErrorsChanged = !isEqual(
      externalErrors,
      oldState.externalErrors,
    );

    const updatedExternalErrors = externalErrorsChanged
      ? {
          externalErrors,
          fields: fieldsWithErrors(oldState.fields, [
            ...externalErrors,
            ...oldState.errors,
          ]),
        }
      : null;

    switch (onInitialValuesChange) {
      case 'ignore':
        return updatedExternalErrors;
      case 'reset-where-changed':
        return reconcileFormState(initialValues, oldState, externalErrors);
      case 'reset-all':
      default:
        const oldInitialValues = initialValuesFromFields(oldState.fields);
        const valuesMatch = isEqual(oldInitialValues, initialValues);

        if (valuesMatch) {
          return updatedExternalErrors;
        }

        return createFormState(initialValues, externalErrors);
    }
  }

  state = createFormState(this.props.initialValues, this.props.externalErrors);
  private mounted = false;
  private fieldsWithHandlers = new WeakMap();

  componentDidMount() {
    this.mounted = true;
  }

  componentWillUnmount() {
    this.mounted = false;
  }

  render() {
    const {children} = this.props;
    const {submitting} = this.state;
    const {submit, reset, formData} = this;

    return children({
      ...formData,
      submit,
      reset,
      submitting,
    });
  }

  public validateForm() {
    return new Promise(resolve => {
      this.setState(runAllValidators, () => resolve());
    });
  }

  public reset = () => {
    return new Promise(resolve => {
      this.setState(
        (_state, props) =>
          createFormState(props.initialValues, props.externalErrors),
        () => resolve(),
      );
    });
  };

  private get formData() {
    const {errors} = this.state;
    const {externalErrors = []} = this.props;
    const {fields, dirty, valid} = this;

    return {
      dirty,
      valid,
      errors: [...errors, ...externalErrors],
      fields,
    };
  }

  private get dirty() {
    return this.state.dirtyFields.length > 0;
  }

  private get valid() {
    const {errors, externalErrors} = this.state;

    return (
      !this.hasClientErrors &&
      errors.length === 0 &&
      externalErrors.length === 0
    );
  }

  private get hasClientErrors() {
    const {fields} = this.state;

    return Object.keys(fields).some(fieldPath => {
      const field = fields[fieldPath];
      return field.error != null;
    });
  }

  private get fields() {
    const {fields} = this.state;
    const fieldDescriptors: FieldDescriptors<Fields> = mapObject(
      fields,
      this.fieldWithHandlers,
    );

    return fieldDescriptors;
  }

  private submit = async (event?: Event) => {
    const {onSubmit, validateOnSubmit} = this.props;
    const {formData} = this;

    if (!this.mounted) {
      return;
    }

    if (event && event.preventDefault && !event.defaultPrevented) {
      event.preventDefault();
    }

    if (onSubmit == null) {
      return;
    }

    this.setState({submitting: true});

    if (validateOnSubmit) {
      await this.validateForm();

      if (this.hasClientErrors) {
        this.setState({submitting: false});
        return;
      }
    }

    const errors = (await onSubmit(formData)) || [];

    if (!this.mounted) {
      return;
    }

    if (errors.length > 0) {
      this.updateRemoteErrors(errors);
      this.setState({submitting: false});
    } else {
      this.setState({submitting: false, errors});
    }
  };

  private fieldWithHandlers = <Key extends keyof Fields>(
    field: FieldStates<Fields>[Key],
    fieldPath: Key,
  ) => {
    if (this.fieldsWithHandlers.has(field)) {
      // eslint-disable-next-line typescript/no-non-null-assertion
      return this.fieldsWithHandlers.get(field)!;
    }

    const result = {
      ...(field as FieldState<Fields[Key]>),
      name: String(fieldPath),
      onChange: this.updateField.bind(this, fieldPath),
      onBlur: this.blurField.bind(this, fieldPath),
    };
    this.fieldsWithHandlers.set(field, result);
    return result;
  };

  private updateField<Key extends keyof Fields>(
    fieldPath: Key,
    value: Fields[Key] | ValueMapper<Fields[Key]>,
  ) {
    this.setState<any>(({fields, dirtyFields}: State<Fields>) => {
      const field = fields[fieldPath];

      const newValue =
        typeof value === 'function'
          ? (value as ValueMapper<Fields[Key]>)(field.value)
          : value;

      const dirty = !isEqual(newValue, field.initialValue);

      const updatedField = this.getUpdatedField({
        fieldPath,
        field,
        value: newValue,
        dirty,
      });

      return {
        dirtyFields: this.getUpdatedDirtyFields({
          fieldPath,
          dirty,
          dirtyFields,
        }),
        fields:
          updatedField === field
            ? fields
            : {
                // FieldStates<Fields> is not spreadable due to a TS bug
                // https://github.com/Microsoft/TypeScript/issues/13557
                ...(fields as any),
                [fieldPath]: updatedField,
              },
      };
    });
  }

  private getUpdatedDirtyFields<Key extends keyof Fields>({
    fieldPath,
    dirty,
    dirtyFields,
  }: {
    fieldPath: Key;
    dirty: boolean;
    dirtyFields: (keyof Fields)[];
  }) {
    const dirtyFieldsSet = new Set(dirtyFields);

    if (dirty) {
      dirtyFieldsSet.add(fieldPath);
    } else {
      dirtyFieldsSet.delete(fieldPath);
    }

    const newDirtyFields = Array.from(dirtyFieldsSet);

    return dirtyFields.length === newDirtyFields.length
      ? dirtyFields
      : newDirtyFields;
  }

  private getUpdatedField<Key extends keyof Fields>({
    fieldPath,
    field,
    value,
    dirty,
  }: {
    fieldPath: Key;
    field: FieldStates<Fields>[Key];
    value: Fields[Key];
    dirty: boolean;
  }) {
    // We only want to update errors as the user types if they already have an error.
    // https://polaris.shopify.com/patterns/error-messages#section-form-validation
    const skipValidation = field.error == null;
    const error = skipValidation
      ? field.error
      : this.validateFieldValue(fieldPath, {value, dirty});

    if (value === field.value && error === field.error) {
      return field;
    }

    return {
      ...(field as FieldState<Fields[Key]>),
      value,
      dirty,
      error,
    };
  }

  private blurField<Key extends keyof Fields>(fieldPath: Key) {
    const {fields} = this.state;
    const field = fields[fieldPath];
    const error = this.validateFieldValue<Key>(fieldPath, field);

    if (error == null) {
      return;
    }

    this.setState(state => ({
      fields: {
        // FieldStates<Fields> is not spreadable due to a TS bug
        // https://github.com/Microsoft/TypeScript/issues/13557
        ...(state.fields as any),
        [fieldPath]: {
          ...(state.fields[fieldPath] as FieldState<Fields[Key]>),
          error,
        },
      },
    }));
  }

  private validateFieldValue<Key extends keyof Fields>(
    fieldPath: Key,
    {value, dirty}: Pick<FieldState<Fields[Key]>, 'value' | 'dirty'>,
  ) {
    if (!dirty) {
      return;
    }

    const {validators = {}} = this.props;
    const {fields} = this.state;

    // eslint-disable-next-line consistent-return
    return runValidator(validators[fieldPath], value, fields);
  }

  private updateRemoteErrors(errors: RemoteError[]) {
    this.setState(({fields, externalErrors}) => ({
      errors,
      fields: fieldsWithErrors(fields, [...errors, ...externalErrors]),
    }));
  }
}

function fieldsWithErrors<Fields>(
  fields: Fields,
  errors: RemoteError[],
): Fields {
  const errorDictionary = errors.reduce(
    (accumulator: any, {field, message}) => {
      if (field == null) {
        return accumulator;
      }

      return set(accumulator, field, message);
    },
    {},
  );

  return mapObject(fields, (field, path) => {
    if (!errorDictionary[path]) {
      return field;
    }

    return {
      ...field,
      error: errorDictionary[path],
    };
  });
}

function reconcileFormState<Fields>(
  values: Fields,
  oldState: State<Fields>,
  externalErrors: RemoteError[] = [],
): State<Fields> {
  const {fields: oldFields} = oldState;
  const dirtyFields = new Set(oldState.dirtyFields);

  const fields: FieldStates<Fields> = mapObject(values, (value, key) => {
    const oldField = oldFields[key];

    if (isEqual(value, oldField.initialValue)) {
      return oldField;
    }

    dirtyFields.delete(key);

    return {
      value,
      initialValue: value,
      dirty: false,
    };
  });

  return {
    ...oldState,
    dirtyFields: Array.from(dirtyFields),
    fields: fieldsWithErrors(fields, externalErrors),
  };
}

function createFormState<Fields>(
  values: Fields,
  externalErrors: RemoteError[] = [],
): State<Fields> {
  const fields: FieldStates<Fields> = mapObject(values, value => {
    return {
      value,
      initialValue: value,
      dirty: false,
    };
  });

  return {
    dirtyFields: [],
    errors: [],
    submitting: false,
    externalErrors,
    fields: fieldsWithErrors(fields, externalErrors),
  };
}

function initialValuesFromFields<Fields>(fields: FieldStates<Fields>): Fields {
  return mapObject(fields, ({initialValue}) => initialValue);
}

function runValidator<T, F>(
  validate: Validator<T, F> = () => {},
  value: T,
  fields: FieldStates<F>,
) {
  if (typeof validate === 'function') {
    // eslint-disable-next-line consistent-return
    return validate(value, fields);
  }

  if (!Array.isArray(validate)) {
    // eslint-disable-next-line consistent-return
    return;
  }

  const errors = validate
    .map(validator => validator(value, fields))
    .filter(input => input != null);

  if (errors.length === 0) {
    // eslint-disable-next-line consistent-return
    return;
  }

  // eslint-disable-next-line consistent-return
  return errors;
}

function runAllValidators<FieldMap>(
  state: State<FieldMap>,
  props: Props<FieldMap>,
) {
  const {fields} = state;
  const {validators} = props;

  if (!validators) {
    return null;
  }

  const updatedFields = mapObject(fields, (field, path) => {
    return {
      ...field,
      error: runValidator(validators[path], field.value, fields),
    };
  });

  return {
    ...state,
    fields: updatedFields,
  } as State<FieldMap>;
}
