import {
  CSSProperties,
  ElementType,
  useContext,
  useMemo,
  useState,
} from 'react';
import classNames from 'classnames';
import {
  Form as FormikFormWrapper,
  Formik,
  FormikHelpers,
  useFormikContext,
} from 'formik';
import FocusError from './FocusError';
import FormBasedPreventNavigation from './FormBasedPreventNavigation';
import {
  ServerErrorContext,
  ServerErrorContextProps,
  ServerErrors,
} from './ServerErrorContext';
import Field, { FieldProps } from '../Field/Field';
import FieldArray, { FieldArrayProps } from '../FieldArray/FieldArray';
import { FormDefaults } from '../FormDefaults';
import objectContainsNonSerializableProperty from '../utils/objectContainsNonSerializableProperty';
import objectToFormData from '../utils/objectToFormData';
import { ValidatedApiResult } from '../Validation/ValidatedApiResult';
import { ValidationError } from '../Validation/ValidationError';

// This exposes the builder that ensures only "name" values on the given TForm can be used
// Further, each Field can then infer the proper type given the name
export type FormBuilderProp<TForm extends object> = {
  // the first set of <> is a generic method signature - we don't split this off because we need the TForm closure
  Field: <TProp extends keyof TForm, TRenderComponent extends ElementType>(
    props: FieldProps<TForm, TProp, TRenderComponent>
  ) => JSX.Element; // assumes this is never null - thought he final component may not render

  FieldArray: <TProp extends keyof TForm>(
    props: FieldArrayProps<TForm, TProp>
  ) => JSX.Element;
};

export interface FullFormProps<TForm extends object> {
  /** The `<Field/>` and `<FieldArray/>` components. */
  children: (formBuilder: FormBuilderProp<TForm>) => JSX.Element;
  /** Submission handler  */
  onSubmit: (
    formValues: TForm,
    formikBag: FormikHelpers<TForm>
  ) => Promise<ValidatedApiResult>;
  /** Submission handler for forms that use [FormData](https://developer.mozilla.org/en-US/docs/Web/API/FormData).*/
  onFormDataSubmit: (
    formValues: FormData,
    formikBag: FormikHelpers<TForm>
  ) => Promise<ValidatedApiResult>;
  className?: string;
  style?: CSSProperties;
  /** Prevent the user from leaving the form if they have edited any field. This is presented as a JS `alert()`. */
  ignoreLostChanges?: boolean;
  /** The intitial values of the form. */
  initialValues?: TForm;
}

type RequireAtLeastOne<T, Keys extends keyof T = keyof T> = Pick<
  T,
  Exclude<keyof T, Keys>
> &
  {
    [K in Keys]-?: Required<Pick<T, K>> & Partial<Pick<T, Exclude<Keys, K>>>;
  }[Keys];

export type FormProps<TForm extends object> = RequireAtLeastOne<
  FullFormProps<TForm>,
  'onSubmit' | 'onFormDataSubmit'
>;

/** Define a form. Uses [formik](https://formik.org/docs/overview). Usually contains many `<Field/>` components. */
export default function Form<TForm extends object>({
  children,
  className,
  style,
  ignoreLostChanges,
  onSubmit,
  onFormDataSubmit,
  initialValues,
  ...props
}: FormProps<TForm>) {
  // formik resets all error on each blur (with our settings)
  // this means that ALL errors from the server disappear when any one field is blurred
  // So, we have to store server errors ourselves
  // Since we only use useStandardFormInput, that means there is only one consumer
  const [serverErrors, setServerErrors] = useState<ServerErrors>({});
  const serverErrorContextValue = useMemo<ServerErrorContextProps>(
    () => ({
      errors: serverErrors,
      getError: (path) => {
        const lowered = path.toLowerCase();
        return serverErrors && serverErrors[lowered];
      },
      setError: (path, errorMessage) => {
        const lowered = path.toLowerCase();
        setServerErrors(
          Object.assign({}, serverErrors, {
            [lowered]: !errorMessage ? undefined : errorMessage,
          })
        );
      },
    }),
    [serverErrors]
  );

  return (
    <Formik
      validateOnChange={false}
      validateOnBlur={true}
      validateOnMount={false}
      initialValues={initialValues || ({} as TForm)}
      onSubmit={handleSubmit}
      {...props}>
      <ServerErrorContext.Provider value={serverErrorContextValue}>
        <FormikFormWrapper
          className={classNames(
            className,
            FormDefaults.cssClassPrefix + 'form'
          )}
          style={style}>
          <FocusError serverErrors={serverErrorContextValue} />
          <FormBasedPreventNavigation ignoreLostChanges={ignoreLostChanges} />
          {children({
            // hack for ref forwarding
            Field: Field as any,
            FieldArray: FieldArray as any,
          })}
        </FormikFormWrapper>
      </ServerErrorContext.Provider>
    </Formik>
  );

  function handleSubmit(values: TForm, formikBag: FormikHelpers<TForm>) {
    let formData: FormData | undefined = undefined;
    let submitFunc: () => Promise<ValidatedApiResult>;
    if (objectContainsNonSerializableProperty(values)) {
      formData = objectToFormData(values, {
        indices: true,
        dotNotation: true,
        allowEmptyArrays: true,
        noFileListBrackets: true,
      });
      if (onFormDataSubmit === undefined) {
        throw new Error(
          'No onFormDataSubmit supplied for non-serializable properties.'
        );
      }
      submitFunc = () =>
        onFormDataSubmit(formData ?? new FormData(), formikBag);
    } else {
      if (onSubmit === undefined) {
        formData = objectToFormData(values, {
          indices: true,
          dotNotation: true,
          allowEmptyArrays: true,
          noFileListBrackets: true,
        });
        if (onFormDataSubmit === undefined) {
          // This error should never occur, as this case is covered by RequireAtLeastOne type safety
          throw new Error(
            'No onFormDataSubmit supplied for non-serializable properties.'
          );
        }
        submitFunc = () =>
          onFormDataSubmit(formData ?? new FormData(), formikBag);
      } else {
        submitFunc = () => onSubmit(values, formikBag);
      }
    }

    return submitFunc()
      .then((response) => {
        return response;
      })
      .catch((err) => {
        //this is an http error
        if (
          err &&
          err.response &&
          err.response.data &&
          err.response.data.validationFailures
        ) {
          try {
            const serverErrors = err.response.data.validationFailures.reduce(
              (acc: ServerErrors, value: ValidationError) => {
                // for simplicity, just keep it to one server error at a time per path
                // don't care of the property name case changes
                const path = value.propertyName?.toLowerCase();
                if (!!path && !!value.errorMessage) {
                  acc[path] = value.errorMessage;
                }
                return acc;
              },
              {} as ServerErrors
            );
            setServerErrors(serverErrors);
          } catch (err) {
            console.error('Failure to getErrorObject');
            console.error(err);
            throw err;
          }
        }
        throw err;
      });
  }
}

Form.DisplayFormState = DisplayFormState;
function DisplayFormState() {
  const formState = useFormikContext();
  const serverErrorContext = useContext(ServerErrorContext);
  return (
    <div style={{ margin: '1rem 0' }}>
      <pre
        style={{
          background: '#f6f8fa',
          fontSize: '.65rem',
          padding: '.5rem',
        }}>
        {serverErrorContext && serverErrorContext.errors && (
          <div>
            <strong>serverErrors = </strong>
            {JSON.stringify(serverErrorContext.errors, null, 2)}
          </div>
        )}
        <strong>formState = </strong>
        {JSON.stringify(formState, null, 2)}
      </pre>
    </div>
  );
}
