/*
 * Copyright 2023 Adobe. All rights reserved.
 * This file is licensed to you under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License. You may obtain a copy
 * of the License at http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software distributed under
 * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
 * OF ANY KIND, either express or implied. See the License for the specific language
 * governing permissions and limitations under the License.
 */

import {Context, createContext, useContext, useEffect, useMemo, useRef, useState} from 'react';
import {Validation, ValidationErrors, ValidationFunction, ValidationResult} from '@react-types/shared';

export const VALID_VALIDITY_STATE: ValidityState = {
  badInput: false,
  customError: false,
  patternMismatch: false,
  rangeOverflow: false,
  rangeUnderflow: false,
  stepMismatch: false,
  tooLong: false,
  tooShort: false,
  typeMismatch: false,
  valueMissing: false,
  valid: true
};

const CUSTOM_VALIDITY_STATE: ValidityState = {
  ...VALID_VALIDITY_STATE,
  customError: true,
  valid: false
};

export const DEFAULT_VALIDATION_RESULT: ValidationResult = {
  isInvalid: false,
  validationDetails: VALID_VALIDITY_STATE,
  validationErrors: []
};

export const FormValidationContext: Context<ValidationErrors> = createContext<ValidationErrors>({});

export const privateValidationStateProp: string = '__formValidationState' + Date.now();

interface FormValidationProps<T> extends Validation<T> {
  builtinValidation?: ValidationResult,
  name?: string | string[],
  value: T | null
}

export interface FormValidationState {
  /** Realtime validation results, updated as the user edits the value. */
  realtimeValidation: ValidationResult,
  /** Currently displayed validation results, updated when the user commits their changes. */
  displayValidation: ValidationResult,
  /** Updates the current validation result. Not displayed to the user until `commitValidation` is called. */
  updateValidation(result: ValidationResult): void,
  /** Resets the displayed validation state to valid when the user resets the form. */
  resetValidation(): void,
  /** Commits the realtime validation so it is displayed to the user. */
  commitValidation(): void
}

export function useFormValidationState<T>(props: FormValidationProps<T>): FormValidationState {
  // Private prop for parent components to pass state to children.
  if (props[privateValidationStateProp]) {
    let {realtimeValidation, displayValidation, updateValidation, resetValidation, commitValidation} = props[privateValidationStateProp] as FormValidationState;
    return {realtimeValidation, displayValidation, updateValidation, resetValidation, commitValidation};
  }

  // eslint-disable-next-line react-hooks/rules-of-hooks
  return useFormValidationStateImpl(props);
}

function useFormValidationStateImpl<T>(props: FormValidationProps<T>): FormValidationState {
  let {isInvalid, validationState, name, value, builtinValidation, validate, validationBehavior = 'aria'} = props;

  // backward compatibility.
  if (validationState) {
    isInvalid ||= validationState === 'invalid';
  }

  // If the isInvalid prop is controlled, update validation result in realtime.
  let controlledError: ValidationResult | null = isInvalid !== undefined ? {
    isInvalid,
    validationErrors: [],
    validationDetails: CUSTOM_VALIDITY_STATE
  } : null;

  // Perform custom client side validation.
  let clientError: ValidationResult | null = useMemo(() => {
    if (!validate || value == null) {
      return null;
    }
    let validateErrors = runValidate(validate, value);
    return getValidationResult(validateErrors);
  }, [validate, value]);

  if (builtinValidation?.validationDetails.valid) {
    builtinValidation = undefined;
  }

  // Get relevant server errors from the form.
  let serverErrors = useContext(FormValidationContext);
  let serverErrorMessages = useMemo(() => {
    if (name) {
      return Array.isArray(name) ? name.flatMap(name => asArray(serverErrors[name])) : asArray(serverErrors[name]);
    }
    return [];
  }, [serverErrors, name]);

  // Show server errors when the form gets a new value, and clear when the user changes the value.
  let [lastServerErrors, setLastServerErrors] = useState(serverErrors);
  let [isServerErrorCleared, setServerErrorCleared] = useState(false);
  if (serverErrors !== lastServerErrors) {
    setLastServerErrors(serverErrors);
    setServerErrorCleared(false);
  }

  let serverError: ValidationResult | null = useMemo(() =>
    getValidationResult(isServerErrorCleared ? [] : serverErrorMessages),
    [isServerErrorCleared, serverErrorMessages]
  );

  // Track the next validation state in a ref until commitValidation is called.
  let nextValidation = useRef(DEFAULT_VALIDATION_RESULT);
  let [currentValidity, setCurrentValidity] = useState(DEFAULT_VALIDATION_RESULT);

  let lastError = useRef(DEFAULT_VALIDATION_RESULT);
  let commitValidation = () => {
    if (!commitQueued) {
      return;
    }

    setCommitQueued(false);
    let error = clientError || builtinValidation || nextValidation.current;
    if (!isEqualValidation(error, lastError.current)) {
      lastError.current = error;
      setCurrentValidity(error);
    }
  };

  let [commitQueued, setCommitQueued] = useState(false);
  useEffect(commitValidation);

  // realtimeValidation is used to update the native input element's state based on custom validation logic.
  // displayValidation is the currently displayed validation state that the user sees (e.g. on input change/form submit).
  // With validationBehavior="aria", all errors are displayed in realtime rather than on submit.
  let realtimeValidation = controlledError || serverError || clientError || builtinValidation || DEFAULT_VALIDATION_RESULT;
  let displayValidation = validationBehavior === 'native'
    ? controlledError || serverError || currentValidity
    : controlledError || serverError || clientError || builtinValidation || currentValidity;

  return {
    realtimeValidation,
    displayValidation,
    updateValidation(value) {
      // If validationBehavior is 'aria', update in realtime. Otherwise, store in a ref until commit.
      if (validationBehavior === 'aria' && !isEqualValidation(currentValidity, value)) {
        setCurrentValidity(value);
      } else {
        nextValidation.current = value;
      }
    },
    resetValidation() {
      // Update the currently displayed validation state to valid on form reset,
      // even if the native validity says it isn't. It'll show again on the next form submit.
      let error = DEFAULT_VALIDATION_RESULT;
      if (!isEqualValidation(error, lastError.current)) {
        lastError.current = error;
        setCurrentValidity(error);
      }

      // Do not commit validation after the next render. This avoids a condition where
      // useSelect calls commitValidation inside an onReset handler.
      if (validationBehavior === 'native') {
        setCommitQueued(false);
      }

      setServerErrorCleared(true);
    },
    commitValidation() {
      // Commit validation state so the user sees it on blur/change/submit. Also clear any server errors.
      // Wait until after the next render to commit so that the latest value has been validated.
      if (validationBehavior === 'native') {
        setCommitQueued(true);
      }
      setServerErrorCleared(true);
    }
  };
}

function asArray<T>(v: T | T[]): T[] {
  if (!v) {
    return [];
  }

  return Array.isArray(v) ? v : [v];
}

function runValidate<T>(validate: ValidationFunction<T>, value: T): string[] {
  if (typeof validate === 'function') {
    let e = validate(value);
    if (e && typeof e !== 'boolean') {
      return asArray(e);
    }
  }

  return [];
}

function getValidationResult(errors: string[]): ValidationResult | null {
  return errors.length ? {
    isInvalid: true,
    validationErrors: errors,
    validationDetails: CUSTOM_VALIDITY_STATE
  } : null;
}

function isEqualValidation(a: ValidationResult | null, b: ValidationResult | null): boolean {
  if (a === b) {
    return true;
  }

  return !!a && !!b
    && a.isInvalid === b.isInvalid
    && a.validationErrors.length === b.validationErrors.length
    && a.validationErrors.every((a, i) => a === b.validationErrors[i])
    && Object.entries(a.validationDetails).every(([k, v]) => b.validationDetails[k] === v);
}

export function mergeValidation(...results: ValidationResult[]): ValidationResult {
  let errors = new Set<string>();
  let isInvalid = false;
  let validationDetails = {
    ...VALID_VALIDITY_STATE
  };

  for (let v of results) {
    for (let e of v.validationErrors) {
      errors.add(e);
    }

    // Only these properties apply for checkboxes.
    isInvalid ||= v.isInvalid;
    for (let key in validationDetails) {
      validationDetails[key] ||= v.validationDetails[key];
    }
  }

  validationDetails.valid = !isInvalid;
  return {
    isInvalid,
    validationErrors: [...errors],
    validationDetails
  };
}
