import { useSearchParams } from 'react-router-dom'
import { useMemo, useEffect, useRef, useCallback } from 'react'


// Custom hook with advanced techniques to handle search parameters for any pagination

type CommonParams = {
  page?: number
  page_size?: number
}
/*
Maps all properties of M (mandatory) as required
and all properties of O (optional) as optional. 
*/
type MergeParams<M, O> = {
  [K in keyof M]: M[K]
} & {
  [K in keyof O]?: O[K]
}
/**
 * Interface for the configuration object that the hook receives 
 */
export interface UseMagicSearchParamsOptions<
  M extends Record<string, unknown>,
  O extends Record<string, unknown>
> {
  mandatory: M 
  optional?: O
  defaultParams?: Partial<MergeParams<M, O>>
  forceParams?: Partial<MergeParams<M, O>> // transform all to partial to avoid errors
  arraySerialization?: 'csv' | 'repeat' | 'brackets' // technical to serialize arrays in the URL
  omitParamsByValues?: Array<'all' | 'default' | 'unknown' | 'none' | 'void '> 
}

/** 
Generic hook to handle search parameters in the URL
@param mandatory - Mandatory parameters (e.g., page=1, page_size=10, etc.)
@param optional - Optional parameters (e.g., order, search, etc.)
@param defaultParams - Default parameters sent in the URL on initialization
@param forceParams - Parameters forced into the URL regardless of user input
@param omitParamsByValues - Parameters omitted if they have specific values 
*/
export const useMagicSearchParams = <
  M extends Record<string, unknown> & CommonParams,
  O extends Record<string, unknown>,
>({
  mandatory = {} as M,
  optional = {} as O,
  defaultParams = {} as Partial<MergeParams<M, O>>,
  arraySerialization = 'csv',
  forceParams = {} as  {} as Partial<MergeParams<M, O>>,
  omitParamsByValues = [] as Array<'all' | 'default' | 'unknown' | 'none' | 'void '>
}: UseMagicSearchParamsOptions<M, O>)=> {

  const [searchParams, setSearchParams] = useSearchParams() 
    // Ref to store subscriptions: { paramName: [callback1, callback2, ...] }
  const subscriptionsRef = useRef<Record<string, Array<() => unknown>>>({}); 
  const previousParamsRef = useRef<Record<string, unknown>>({})

  const TOTAL_PARAMS_PAGE: MergeParams<M, O> = useMemo(() => {
    return { ...mandatory, ...optional };
  }, [mandatory, optional]);

  const PARAM_ORDER = useMemo(() => {
    return Array.from(Object.keys(TOTAL_PARAMS_PAGE))
  }, [TOTAL_PARAMS_PAGE])

  // we get the keys that are arrays according to TOTAL_PARAMS_PAGE since these require special treatment in the URL due to serialization mode
  const ARRAY_KEYS = useMemo(() => {
    return Object.keys(TOTAL_PARAMS_PAGE).filter(
      (key) => Array.isArray(TOTAL_PARAMS_PAGE[key])
    );
  }, [TOTAL_PARAMS_PAGE])

  const appendArrayValues = (
    finallyParams: Record<string, unknown>,
    newParams: Record<string, string | string[] | unknown>
  ): Record<string, unknown> => {
 
    // Note: We cannot modify the object of the final parameters directly, as immutability must be maintained
    const updatedParams = { ...finallyParams };
  
    if (ARRAY_KEYS.length === 0) return updatedParams;
  
    ARRAY_KEYS.forEach((key) => {
      // We use the current values directly from searchParams (source of truth)
      // This avoids depending on finallyParams in which the arrays have been omitted
      let currentValues = []; 
      switch (arraySerialization) {
        case 'csv': {
          const raw = searchParams.get(key) || '';
          // For csv we expect "value1,value2,..." (no prefix)
          currentValues = raw.split(',')
            .map((v) => v.trim())
            .filter(Boolean) as Array<string>
          break;
        }
        case 'repeat': {
          // Here we get all ocurrences of key
          const urlParams = searchParams.getAll(key) as Array<string>
          currentValues = urlParams.length > 0 ? urlParams : []

          break;
        }
        case 'brackets': {
           // Build URLSearchParams from current parameters (to ensure no serialized values are taken previously)
            const urlParams = searchParams.getAll(`${key}[]`) as Array<string>
            currentValues = urlParams.length > 0 ? urlParams : []

            break;
        }
        default: {
          // Mode by default works as csv
          const raw = searchParams.get(key) ?? '';
          currentValues = raw.split(',')
            .map((v) => v.trim())
            .filter(Boolean);
          }
        break; 
      }
      // Update array values with new ones
    
      if (newParams[key] !== undefined) {
        const incoming = newParams[key];
        let combined: string[] = []
        if (typeof incoming === 'string') {
          // If it is a string, it is toggled (add/remove)
          combined = currentValues.includes(incoming)
            ? currentValues.filter((v) => v !== incoming)
            : [...currentValues, incoming];
        } else if (Array.isArray(incoming)) {
          // if an array is passed, repeated values are merged into a single value
          // Note: Set is used to remove duplicates
          combined = Array.from(new Set([ ...incoming]));

        } else {
       
          combined = currentValues;
        }

        updatedParams[key] = combined

      }
    });
    return updatedParams
  };

  const transformParamsToURLSearch = (params: Record<string, unknown>): URLSearchParams => {
    console.log({PARAMS_RECIBIDOS_TRANSFORM: params})

    const newParam: URLSearchParams = new URLSearchParams()

    const paramsKeys = Object.keys(params)

    for (const key of paramsKeys) {
      if (Array.isArray(TOTAL_PARAMS_PAGE[key])) {
        const arrayValue = params[key] as unknown[]
        console.log({arrayValue})
        switch (arraySerialization) {
          case 'csv': {
            const csvValue = arrayValue.join(',')
            // set ensure that the previous value is replaced
            newParam.set(key, csvValue)
            break
          } case 'repeat': {
      
            for (const item of arrayValue) {

              // add new value to the key, instead of replacing it
              newParam.append(key, item as string)
   
            }
            break
          } case 'brackets': {
            for (const item of arrayValue) {
              newParam.append(`${key}[]`, item as string)
            }
            break
          } default: {
            const csvValue = arrayValue.join(',')
            newParam.set(key, csvValue)
          }
        }
      } else {
        newParam.set(key, params[key] as string)
      }
    }
    return newParam
  }
  // @ts-ignore
  const hasForcedParamsValues = ({ paramsForced, compareParams }) => {

    // Iterates over the forced parameters and verifies that they exist in the URL and match their values
    // Ej: { page: 1, page_size: 10 } === { page: 1, page_size: 10 } => true
    const allParamsMatch = Object.entries(paramsForced).every(
      ([key, value]) => compareParams[key] === value
    );

    return allParamsMatch;
  };
  
  useEffect(() => {

    const keysDefaultParams: string[] = Object.keys(defaultParams)
    const keysForceParams: string[] = Object.keys(forceParams)
    if(keysDefaultParams.length === 0 && keysForceParams.length === 0) return
  

    function handleStartingParams() {

      const defaultParamsString  = transformParamsToURLSearch(defaultParams).toString()
      const paramsUrl = getParams()
      const paramsUrlString = transformParamsToURLSearch(paramsUrl).toString()
      const forceParamsString = transformParamsToURLSearch(forceParams).toString()

      console.log({defaultParamsString})

      const isForcedParams: boolean = hasForcedParamsValues({ paramsForced: forceParams, compareParams: paramsUrl })

      if (!isForcedParams) {

        // In this case, the forced parameters take precedence over the default parameters and the parameters of the current URL (which could have been modified by the user, e.g., page_size=1000)

        updateParams({ newParams: {
          ...defaultParams,
          ...forceParams
        }})
        return
      }
      // In this way it will be validated that the forced parameters keys and values are in the current URL
      const isIncludesForcedParams = hasForcedParamsValues({ paramsForced: forceParamsString, compareParams: defaultParams })

      if (keysDefaultParams.length > 0 && isIncludesForcedParams) {
        if (defaultParamsString === paramsUrlString) return // this means that the URL already has the default parameters
        updateParams({ newParams: defaultParams })
      }

    }
    handleStartingParams()

    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [])

  /**
   * Convert a string value to its original type (number, boolean, array) according to TOTAL_PARAMS_PAGE
   * @param value - Chain obtained from the URL
   * @param key - Key of the parameter
   */
  const convertOriginalType = (value: string, key: string) => {
    // Given that the parameters of a URL are recieved as strings, they are converted to their original type
    if (typeof TOTAL_PARAMS_PAGE[key] === 'number') {
      return parseInt(value)
    } else if (typeof TOTAL_PARAMS_PAGE[key] === 'boolean') {
      return value === 'true'
    } else if (Array.isArray(TOTAL_PARAMS_PAGE[key])) {
      // The result will be a valid array represented in the URL ej: tags=tag1,tag2,tag3 to ['tag1', 'tag2', 'tag3'], useful to combine the values of the arrays with the new ones
 
      if (arraySerialization === 'csv') {
        return searchParams.getAll(key).join('').split(',')
      } else if (arraySerialization === 'repeat') {
    
        console.log({SEARCH_PARAMS: searchParams.getAll(key)})
        return searchParams.getAll(key)
      } else if (arraySerialization === 'brackets') {
        return searchParams.getAll(`${key}[]`)
      }
     
     
    }
    // Note: dates are not converted as it is better to handle them directly in the component that receives them, using a library like < date-fns >
    return value
  }
  
    /**
   * Gets the current parameters from the URL and converts them to their original type if desired
   * @param convert - If true, converts from string to the inferred type (number, boolean, ...)
   */
    const getStringUrl = (key: string, paramsUrl: Record<string, unknown>) => {
      const isKeyArray = Array.isArray(TOTAL_PARAMS_PAGE[key])
      if (isKeyArray) {

        if (arraySerialization === 'brackets') {

          const arrayUrl = searchParams.getAll(`${key}[]`)
          const encodedQueryArray = transformParamsToURLSearch({ [key]: arrayUrl }).toString()
          // in this way the array of the URL is decoded to its original form ej: tags[]=tag1&tags[]=tag2&tags[]=tag3
          const unencodeQuery = decodeURIComponent(encodedQueryArray)
          return unencodeQuery
        } else if (arraySerialization === 'csv') {
          const arrayValue = searchParams.getAll(key)
          const encodedQueryArray = transformParamsToURLSearch({ [key]: arrayValue }).toString()
          const unencodeQuery = decodeURIComponent(encodedQueryArray)
          return unencodeQuery
        }
        const arrayValue = searchParams.getAll(key)
        const stringResult = transformParamsToURLSearch({ [key]: arrayValue }).toString()
        return stringResult
      } else {
   
        return paramsUrl[key] as string
      }
     }
     const getParamsObj = (searchParams: URLSearchParams): Record<string, string | string[]> => {
      const paramsObj: Record<string, string | string[]> = {};
      // @ts-ignore
      for (const [key, value] of searchParams.entries()) {
        if (key.endsWith('[]')) {
          const bareKey = key.replace('[]', '');
          if (paramsObj[bareKey]) {
            (paramsObj[bareKey] as string[]).push(value);
          } else {
            paramsObj[bareKey] = [value];
          }
        } else {
          // If the key already exists, it is a repeated parameter
          if (paramsObj[key]) {
            if (Array.isArray(paramsObj[key])) {
              (paramsObj[key] as string[]).push(value);
            } else {
              paramsObj[key] = [paramsObj[key] as string, value];
            }
          } else {
            paramsObj[key] = value;
          }
        }
      }
      return paramsObj;
     }
    // Optimization: While the parameters are not updated, the current parameters of the URL are not recalculated
    const CURRENT_PARAMS_URL: Record<string, unknown> = useMemo(() => {

      return arraySerialization === 'brackets' ? getParamsObj(searchParams) : Object.fromEntries(searchParams.entries())
    }, [searchParams, arraySerialization])

    /**
      * Gets the current parameters from the URL and converts them to their original type if desired
     * @param convert - If true, converts from string to the inferred type (number, boolean, ...)
     * @returns - Returns the current parameters of the URL
     */
    const getParams = ({ convert = true } = {}): MergeParams<M, O> => {
      // All the paramteres are extracted from the URL and converted into an object

      const params = Object.keys(CURRENT_PARAMS_URL).reduce((acc, key) => {
        if (Object.prototype.hasOwnProperty.call(TOTAL_PARAMS_PAGE, key)) {
          const realKey = arraySerialization === 'brackets' ? key.replace('[]', '') : key
          // @ts-ignore
          acc[realKey] = convert === true
            ? convertOriginalType(CURRENT_PARAMS_URL[key] as string, key)
            :  getStringUrl(key, CURRENT_PARAMS_URL)
        }
        return acc
      }, {})
  
      return params as MergeParams<M, O>
    }
  type keys = keyof MergeParams<M, O>
  // Note: in this way the return of the getParam function is typed dynamically, thus having autocomplete in the IDE (eg: value.split(','))
  type TagReturn<T extends boolean> = T extends true ? string[] : string;
  /**
    * Gets the value of a parameter from the URL and converts it to its original type if desired
   * @param key - Key of the parameter
   * @param options - Options to convert the value to its original type, default is true
   * @returns - Returns the value of the parameter
   */

  const getParam = <T extends boolean>(key: keys, options?: { convert: T }): TagReturn<T>  => {

    const keyStr = String(key)
    // @ts-ignore
    const value = options?.convert === true ? convertOriginalType(searchParams.get(keyStr), keyStr) : getStringUrl(keyStr,  CURRENT_PARAMS_URL)
    return value as TagReturn<T> 
  }
  
  type OptionalParamsFiltered = Partial<O>

  const calculateOmittedParameters = (newParams: Record<string, unknown | unknown[]>, keepParams: Record<string, boolean>) => {
    // Calculate the ommited parameters, that is, the parameters that have not been sent in the request
    const params = getParams()
    // hasOw
    // Note: it will be necessary to omit the parameters that are arrays because the idea is not to replace them but to add or remove some values
    const newParamsWithoutArray = Object.entries(newParams).filter(([key,]) => !Array.isArray(TOTAL_PARAMS_PAGE[key]))
    const result = Object.assign({
      ...params,
      ...Object.fromEntries(newParamsWithoutArray),
      ...forceParams // the forced parameters will always be sent and will maintain their value
    })
    const paramsFiltered: OptionalParamsFiltered = Object.keys(result).reduce((acc, key) => {
      // for default no parameters are omitted unless specified in the keepParams object
      if (Object.prototype.hasOwnProperty.call(keepParams, key) && keepParams[key] === false) {
        return acc
      // Note: They array of parameters omitted by values (e.g., ['all', 'default']) are omitted since they are usually a default value that is not desired to be sent
      } else if (!!result[key] !== false && !omitParamsByValues.includes(result[key])) {
        // @ts-ignore
        acc[key] = result[key]
      }

      return acc
    }, {})

    return {
      ...mandatory,
      ...paramsFiltered
    } 
  }
    // @ts-ignore
  const sortParameters = (paramsFiltered) => {
    // sort the parameters according to the structure so that it persists with each change in the URL, eg: localhost:3000/?page=1&page_size=10
    // Note: this visibly improves the user experience
    const orderedParams = PARAM_ORDER.reduce((acc, key) => {
      if (Object.prototype.hasOwnProperty.call(paramsFiltered, key)) {
          // @ts-ignore
        acc[key] = paramsFiltered[key]
      }

      return acc
    }, {})
    return orderedParams
  }

  const mandatoryParameters = () => {
    // Note: in case there are arrays in the URL, they are converted to their original form ej: tags=['tag1', 'tag2'] otherwise the parameters are extracted without converting to optimize performance
    const isNecessaryConvert: boolean = ARRAY_KEYS.length > 0 ? true : false
    const totalParametros: Record<string, unknown>  = getParams({ convert: isNecessaryConvert })

    const paramsUrlFound: Record<string, boolean> = Object.keys(totalParametros).reduce(
      (acc, key) => {
        if (Object.prototype.hasOwnProperty.call(mandatory, key)) {
          // @ts-ignore
          acc[key] = totalParametros[key]
        }
        return acc
      },
      {}
    )

    return paramsUrlFound
  }
  /**
   clears the parameters of the URL, keeping the mandatory parameters
   * @param keepMandatoryParams - If true, the mandatory parameters are kept in the URL
   */

  const clearParams = ({ keepMandatoryParams = true } = {}): void => {
    // for default, the mandatory parameters are not cleared since the current pagination would be lost
    const paramsTransformed = transformParamsToURLSearch(
      {
        ...mandatory,
     
         ...(keepMandatoryParams && {
          ...mandatoryParameters()
        }),
        ...forceParams 
      }
    )
    setSearchParams(paramsTransformed) 
  }

  // transforms the keys to boolean to know which parameters to keep
  type KeepParamsTransformedValuesBoolean = Partial<Record<keyof typeof TOTAL_PARAMS_PAGE, boolean>>
  type NewParams = Partial<typeof TOTAL_PARAMS_PAGE> 
  type KeepParams = KeepParamsTransformedValuesBoolean
  /**
   Merges the new parameters with the current ones, omits the parameters that are not sent and sorts them according to the structure
   * @param newParams - New parameters to be sent in the URL
   * @param keepParams - Parameters to keep in the URL, default is true
   */
  const updateParams = ({ newParams = {} as NewParams, keepParams = {} as KeepParams } = {}) => {

    if (
      Object.keys(newParams).length === 0 &&
      Object.keys(keepParams).length === 0
    ) {
      clearParams()
      return
    }
    // @ts-ignore
    const finallyParamters = calculateOmittedParameters(newParams, keepParams)

    const convertedArrayValues = appendArrayValues(finallyParamters, newParams)

    const paramsSorted = sortParameters(convertedArrayValues)

    setSearchParams(transformParamsToURLSearch(paramsSorted))

  }

  /**
   * @param paramName - Name of the parameter to subscribe to
   * @param callbacks - Callbacks to be executed when the parameter changes
   * @returns - Returns the function to unsubscribe
   */
    const onChange = useCallback( (paramName: keys, callbacks: Array<() => void>) => {
      const paramNameStr = String(paramName)
      // replace the previous callbacks with the new ones so as not to accumulate callbacks
      subscriptionsRef.current[paramNameStr] = callbacks;
    }, [])
  
    // each time searchParams changes, we notify the subscribers
    useEffect(() => {

      for (const [key, value] of Object.entries(subscriptionsRef.current)) {

        const newValue = CURRENT_PARAMS_URL[key] ?? null 
        const oldValue = previousParamsRef.current[key] ?? null
        if (newValue !== oldValue) {
          
          for (const callback of value) {
            callback()

          }
        }
        // once the callback is executed, the previous value is updated to ensure that the next time the value changes, the callback is executed
        previousParamsRef.current[key] = newValue
      }

      
    }, [CURRENT_PARAMS_URL])
  return {
    searchParams,
    updateParams,
    clearParams,
    getParams,
    getParam,
    onChange
  }
}

