import type { z } from "zod"

import type { NextSearchParams } from "@/types/params"

type ParseOptions<T extends z.ZodRawShape> = {
  keyRenameMap?: Record<string, string>
  typeHints?: Record<string, "COMMA_SEPARATED_ARRAY">
  schema?: z.ZodObject<T>
}

type ParseOptionsWithSchema<T extends z.ZodRawShape> = ParseOptions<T> & {
  schema: z.ZodObject<T>
}

// This ensures that the return type is correct when a schema is provided
export function parseSearchParams<T extends z.ZodRawShape>(
  searchParams: NextSearchParams,
  options: ParseOptionsWithSchema<T>
): z.infer<typeof options.schema>
export function parseSearchParams<T extends z.ZodRawShape>(
  searchParams: NextSearchParams,
  options?: ParseOptions<T>
): Record<string, any>

/**
 * Parses URL search parameters into a structured object.
 * This function can handle nested objects, arrays, and type validation/transformation using Zod schema.
 *
 * @param searchParams - The object to parse
 * @param options.keyRenameMap - Optional mapping of keys to rename, ie { "oldKey": "newKey" }
 * @param options.typeHints - Optional type hints to force certain keys to be treated as arrays
 * @param options.schema - Pass a Zod schema to validate and transform the parsed search parameters and get a typed return value
 *
 * Supported formats:
 * - Objects: `user.name=John&user.age=30`
 * - Arrays: `tags[0]=javascript&tags[1]=typescript`
 * - Arrays of objects: `tags[0].name=javascript&tags[0].age=30`
 * - Nested arrays: `tags[0].languages[0]=javascript&tags[0].languages[1]=typescript`
 * - Comma-separated arrays: `tags=javascript,typescript`
 *
 * For comma-separated arrays you must use the `typeHints`
 * option to inform the parser that the key should be treated as an array.
 */
export function parseSearchParams<T extends z.ZodRawShape>(
  searchParams: NextSearchParams,
  options?: ParseOptions<T>
) {
  const entries = Object.entries(searchParams)

  const buildObject = getBuilder(options || {})

  const resultObject: Record<string, any> = {}
  for (const [key, value] of entries) {
    const paths = key.split(".")

    if (Array.isArray(value)) {
      throw new Error(
        `Arrays from duplicate keys (?a=1&a=2) are not yet supported.`
      )
    }

    if (!value) {
      continue
    }

    buildObject(resultObject, paths, value)
  }

  if (options?.schema) {
    return options.schema.parse(resultObject)
  }

  return resultObject
}

// Use a higher-order function to avoid passing the options
// object every time we recursively call the builder
function getBuilder<T extends z.ZodRawShape>(options: ParseOptions<T>) {
  const keyRenameMap = options.keyRenameMap || {}
  const typeHints = options.typeHints || {}

  return function buildNestedObject(
    obj: Record<string, any>,
    paths: string[],
    value: string
  ) {
    if (paths.length === 0) return

    const path = paths[0]
    const remainingPaths = paths.slice(1)

    // Extract the key name and optional array index
    const match = path.match(/^([^\[]+)(?:\[(\d+)\])?$/)
    if (!match) return
    const key = keyRenameMap[match[1]] || match[1]
    const index = match[2] ? parseInt(match[2]) : null

    const forceCommaSeparatedArray = typeHints[key] === "COMMA_SEPARATED_ARRAY"
    const hasIndex = index !== null

    // If we've reached the last path, set the value
    if (remainingPaths.length === 0) {
      // This is either an array or a value that is
      // forced to be an array by the typeHints
      if (hasIndex || forceCommaSeparatedArray) {
        if (isNotArray(obj[key])) obj[key] = []

        if (!hasIndex || forceCommaSeparatedArray) {
          obj[key] = value.split(",")
          return
        }

        obj[key][index] = value

        return
      }

      obj[key] = value
      return
    }

    if (hasIndex) {
      // If the key is an array, ensure array and element at index exists
      if (isNotArray(obj[key])) obj[key] = []
      if (!obj[key][index]) obj[key][index] = {}

      buildNestedObject(obj[key][index], remainingPaths, value)
      return
    }

    // Otherwise, it should be an object
    if (!obj[key]) obj[key] = {}
    buildNestedObject(obj[key], remainingPaths, value)
  }
}

function isNotArray(value: any) {
  return !value || typeof value !== "object" || !Array.isArray(value)
}

type SerializeOptions = {
  keyRenameMap?: Record<string, string>
  typeHints?: Record<string, "COMMA_SEPARATED_ARRAY">
  initialSearchParams?: URLSearchParams
}

/**
 * Serializes an object into URL search parameters.
 *
 * @param obj - The object to serialize
 * @param options.keyRenameMap - Optional mapping of keys to rename, ie { "oldKey": "newKey" }
 * @param options.typeHints - Optional type hints to force certain keys to be treated as comma separated arrays
 * @returns URLSearchParams - The serialized URL search parameters
 */
export function serializeSearchParams(
  obj: Record<string, any>,
  options?: SerializeOptions
): URLSearchParams {
  const params = new URLSearchParams(options?.initialSearchParams)

  const keyRenameMap = options?.keyRenameMap || {}
  const typeHints = options?.typeHints || {}

  function buildParams(obj: unknown, prefix: string) {
    if (obj === null || obj === undefined) return

    if (!isRecord(obj)) {
      params.set(prefix, String(obj))
      return
    }

    for (const key in obj) {
      const value = obj[key]

      const renamedKey = keyRenameMap[key] || key

      if (Array.isArray(value)) {
        if (typeHints[key] === "COMMA_SEPARATED_ARRAY") {
          const paramKey = prefix ? `${prefix}.${renamedKey}` : renamedKey
          params.set(paramKey, value.join(","))
          continue
        }

        value.forEach((item, index) => {
          const indexedKey = `${renamedKey}[${index}]`
          const paramKey = prefix ? `${prefix}.${indexedKey}` : indexedKey
          buildParams(item, paramKey)
        })
        continue
      }

      const paramKey = prefix ? `${prefix}.${renamedKey}` : renamedKey
      if (typeof value === "object" && value !== null) {
        buildParams(value, paramKey)
        continue
      }

      params.set(paramKey, String(value))
    }
  }

  buildParams(obj, "")

  return params
}

function isRecord(value: unknown): value is Record<string, unknown> {
  return typeof value === "object" && value !== null
}
