import deepmerge from "deepmerge"
import stringify from "json-stable-stringify-without-jsonify"

import { BookingErrorCodeEnum, REDEMPTION } from "@/constants/booking"
import { Lang } from "@/constants/languages"
import { selectRate } from "@/constants/routes/hotelReservation"
import { env } from "@/env/server"
import * as api from "@/lib/api"
import { badRequestError } from "@/server/errors/trpc"
import { createCounter } from "@/server/telemetry"
import { toApiLang } from "@/server/utils"

import { generateChildrenString } from "@/components/HotelReservation/utils"
import { getCacheClient } from "@/services/dataCache"
import { cache } from "@/utils/cache"
import { chunk } from "@/utils/chunk"
import { sortRoomConfigs } from "@/utils/sort"

import { getHotelPageUrls } from "../contentstack/hotelPage/utils"
import { type RoomFeaturesInput } from "./input"
import {
  type Cities,
  citiesByCountrySchema,
  citiesSchema,
  countriesSchema,
  getHotelIdsSchema,
  hotelsAvailabilitySchema,
  hotelSchema,
  locationsSchema,
  packagesSchema,
  roomFeaturesSchema,
  roomsAvailabilitySchema,
} from "./output"

import type { z } from "zod"

import type { BedTypeSelection } from "@/types/components/hotelReservation/enterDetails/bedType"
import { AvailabilityEnum } from "@/types/components/hotelReservation/selectHotel/selectHotel"
import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter"
import { HotelTypeEnum } from "@/types/enums/hotelType"
import { PointOfInterestGroupEnum } from "@/types/enums/pointOfInterest"
import type {
  DestinationPagesHotelData,
  Room as RoomCategory,
} from "@/types/hotel"
import type { PackagesOutput } from "@/types/requests/packages"
import type {
  HotelsAvailabilityInputSchema,
  HotelsByHotelIdsAvailabilityInputSchema,
  RoomsAvailabilityExtendedInputSchema,
  RoomsAvailabilityInputRoom,
  RoomsAvailabilityOutputSchema,
} from "@/types/trpc/routers/hotel/availability"
import type { HotelInput } from "@/types/trpc/routers/hotel/hotel"
import type {
  CitiesGroupedByCountry,
  CityLocation,
} from "@/types/trpc/routers/hotel/locations"
import type {
  Product,
  Products,
  RateDefinition,
  RedemptionsProduct,
  RoomConfiguration,
} from "@/types/trpc/routers/hotel/roomAvailability"
import type { Endpoint } from "@/lib/api/endpoints"

export function getPoiGroupByCategoryName(category: string | undefined) {
  if (!category) return PointOfInterestGroupEnum.LOCATION
  switch (category) {
    case "Airport":
    case "Bus terminal":
    case "Transportations":
      return PointOfInterestGroupEnum.PUBLIC_TRANSPORT
    case "Amusement park":
    case "Museum":
    case "Sports":
    case "Theatre":
    case "Tourist":
    case "Zoo":
      return PointOfInterestGroupEnum.ATTRACTIONS
    case "Nearby companies":
    case "Fair":
      return PointOfInterestGroupEnum.BUSINESS
    case "Parking / Garage":
      return PointOfInterestGroupEnum.PARKING
    case "Shopping":
    case "Restaurant":
      return PointOfInterestGroupEnum.SHOPPING_DINING
    case "Hospital":
    default:
      return PointOfInterestGroupEnum.LOCATION
  }
}

export const locationsAffix = "locations"

export const TWENTYFOUR_HOURS = 60 * 60 * 24
export async function getCity({
  cityUrl,
  serviceToken,
}: {
  cityUrl: string
  serviceToken: string
}): Promise<Cities> {
  const cacheClient = await getCacheClient()
  return await cacheClient.cacheOrGet(
    cityUrl,
    async () => {
      const url = new URL(cityUrl)
      const cityResponse = await api.get(
        url.pathname as Endpoint,
        { headers: { Authorization: `Bearer ${serviceToken}` } },
        url.searchParams
      )

      if (!cityResponse.ok) {
        return null
      }

      const cityJson = await cityResponse.json()
      const city = citiesSchema.safeParse(cityJson)
      if (!city.success) {
        console.info(`Validation of city failed`)
        console.info(`cityUrl: ${cityUrl}`)
        console.error(city.error)
        return null
      }

      return city.data
    },
    "1d"
  )
}

export async function getCountries({
  lang,
  serviceToken,
  warmup = false,
}: {
  lang: Lang
  serviceToken: string
  warmup?: boolean
}) {
  const cacheClient = await getCacheClient()
  return await cacheClient.cacheOrGet(
    `${lang}:${locationsAffix}:countries`,
    async () => {
      const params = new URLSearchParams({
        language: toApiLang(lang),
      })

      const countryResponse = await api.get(
        api.endpoints.v1.Hotel.countries,
        {
          headers: {
            Authorization: `Bearer ${serviceToken}`,
          },
        },
        params
      )

      if (!countryResponse.ok) {
        throw new Error("Unable to fetch countries")
      }

      const countriesJson = await countryResponse.json()
      const countries = countriesSchema.safeParse(countriesJson)
      if (!countries.success) {
        console.info(`Validation for countries failed`)
        console.error(countries.error)
        return null
      }

      return countries.data
    },
    "1d",
    {
      cacheStrategy: warmup ? "fetch-then-cache" : "cache-first",
    }
  )
}

export async function getCitiesByCountry({
  countries,
  lang,
  affix = locationsAffix,
  serviceToken,
}: {
  countries: string[]
  lang: Lang
  affix?: string
  serviceToken: string
}): Promise<CitiesGroupedByCountry> {
  const cacheClient = await getCacheClient()
  const allCitiesByCountries = await Promise.all(
    countries.map(async (country) => {
      return cacheClient.cacheOrGet(
        `${lang}:${affix}:cities-by-country:${country}`,
        async () => {
          const params = new URLSearchParams({
            language: toApiLang(lang),
          })
          const countryResponse = await api.get(
            api.endpoints.v1.Hotel.Cities.country(country),
            {
              headers: {
                Authorization: `Bearer ${serviceToken}`,
              },
            },
            params
          )

          if (!countryResponse.ok) {
            throw new Error(`Unable to fetch cities by country ${country}`)
          }

          const countryJson = await countryResponse.json()
          const citiesByCountry = citiesByCountrySchema.safeParse(countryJson)
          if (!citiesByCountry.success) {
            console.error(`Unable to parse cities by country ${country}`)
            console.error(citiesByCountry.error)
            throw new Error(`Unable to parse cities by country ${country}`)
          }
          return { ...citiesByCountry.data, country }
        },
        "1d"
      )
    })
  )

  const filteredCitiesByCountries = allCitiesByCountries.map((country) => ({
    ...country,
    data: country.data.filter((city) => city.isPublished),
  }))

  const groupedCitiesByCountry: CitiesGroupedByCountry =
    filteredCitiesByCountries.reduce((acc, { country, data }) => {
      acc[country] = data
      return acc
    }, {} as CitiesGroupedByCountry)

  return groupedCitiesByCountry
}

export async function getLocations({
  lang,
  citiesByCountry,
  serviceToken,
}: {
  lang: Lang
  citiesByCountry: CitiesGroupedByCountry | null
  serviceToken: string
}) {
  const cacheClient = await getCacheClient()
  const countryKeys = Object.keys(citiesByCountry ?? {})
  let cacheKey = `${lang}:locations`

  if (countryKeys.length) {
    cacheKey += `:${countryKeys.join(",")}`
  }

  return await cacheClient.cacheOrGet(
    cacheKey.toLowerCase(),
    async () => {
      const params = new URLSearchParams({
        language: toApiLang(lang),
      })

      const apiResponse = await api.get(
        api.endpoints.v1.Hotel.locations,
        {
          headers: {
            Authorization: `Bearer ${serviceToken}`,
          },
        },
        params
      )
      if (!apiResponse.ok) {
        if (apiResponse.status === 401) {
          throw new Error("unauthorized")
        } else if (apiResponse.status === 403) {
          throw new Error("forbidden")
        }
        throw new Error("downstream error")
      }

      const apiJson = await apiResponse.json()
      const verifiedLocations = locationsSchema.safeParse(apiJson)
      if (!verifiedLocations.success) {
        console.info(`Locations Verification Failed`)
        console.error(verifiedLocations.error)
        throw new Error("Unable to parse locations")
      }
      const chunkedLocations = chunk(verifiedLocations.data.data, 10)

      let locations: z.infer<typeof locationsSchema>["data"] = []

      for (const chunk of chunkedLocations) {
        const chunkLocations = await Promise.all(
          chunk.map(async (location) => {
            if (location.type === "cities") {
              if (citiesByCountry) {
                const country = Object.keys(citiesByCountry).find((country) =>
                  citiesByCountry[country].find(
                    (loc) => loc.name === location.name
                  )
                )
                if (country) {
                  return {
                    ...location,
                    country,
                  }
                } else {
                  console.info(
                    `Location cannot be found in any of the countries cities`
                  )
                  console.info(location)
                }
              }
            } else if (location.type === "hotels") {
              if (location.relationships.city?.url) {
                const city = await getCity({
                  cityUrl: location.relationships.city.url,
                  serviceToken,
                })
                if (city) {
                  return deepmerge(location, {
                    relationships: {
                      city,
                    },
                  })
                }
              }
            }

            return location
          })
        )

        locations.push(...chunkLocations)
      }

      return locations
    },
    "1d"
  )
}

export async function getHotelIdsByCityId({
  cityId,
  serviceToken,
}: {
  cityId: string
  serviceToken: string
}) {
  const getHotelIdsByCityIdCounter = createCounter(
    "hotel",
    "getHotelIdsByCityId"
  )
  const metricsGetHotelIdsByCityId = getHotelIdsByCityIdCounter.init({
    cityId,
  })

  metricsGetHotelIdsByCityId.start()

  const cacheClient = await getCacheClient()
  const result = await cacheClient.cacheOrGet(
    `${cityId}:hotelsByCityId`,
    async () => {
      const searchParams = new URLSearchParams({
        city: cityId,
      })

      const apiResponse = await api.get(
        api.endpoints.v1.Hotel.hotels,
        {
          headers: {
            Authorization: `Bearer ${serviceToken}`,
          },
        },
        searchParams
      )

      if (!apiResponse.ok) {
        await metricsGetHotelIdsByCityId.httpError(apiResponse)
        throw new Error("Unable to fetch hotelIds by cityId")
      }

      const apiJson = await apiResponse.json()
      const validatedHotelIds = getHotelIdsSchema.safeParse(apiJson)
      if (!validatedHotelIds.success) {
        metricsGetHotelIdsByCityId.validationError(validatedHotelIds.error)
        throw new Error("Unable to parse data for hotelIds by cityId")
      }

      return validatedHotelIds.data
    },
    env.CACHE_TIME_HOTELS
  )

  metricsGetHotelIdsByCityId.success()

  return result
}

export async function getHotelIdsByCountry({
  country,
  serviceToken,
}: {
  country: string
  serviceToken: string
}) {
  const getHotelIdsByCountryCounter = createCounter(
    "hotel",
    "getHotelIdsByCountry"
  )

  const metricsGetHotelIdsByCountry = getHotelIdsByCountryCounter.init({
    country,
  })

  metricsGetHotelIdsByCountry.start()

  const cacheClient = await getCacheClient()

  const result = await cacheClient.cacheOrGet(
    `${country}:hotelsByCountry`,
    async () => {
      const hotelIdsParams = new URLSearchParams({
        country,
      })

      const apiResponse = await api.get(
        api.endpoints.v1.Hotel.hotels,
        {
          headers: {
            Authorization: `Bearer ${serviceToken}`,
          },
        },
        hotelIdsParams
      )

      if (!apiResponse.ok) {
        await metricsGetHotelIdsByCountry.httpError(apiResponse)
        throw new Error("Unable to fetch hotelIds by country")
      }

      const apiJson = await apiResponse.json()
      const validatedHotelIds = getHotelIdsSchema.safeParse(apiJson)
      if (!validatedHotelIds.success) {
        metricsGetHotelIdsByCountry.validationError(validatedHotelIds.error)
        throw new Error("Unable to parse hotelIds by country")
      }

      return validatedHotelIds.data
    },
    env.CACHE_TIME_HOTELS
  )

  metricsGetHotelIdsByCountry.success()

  return result
}

export async function getHotelIdsByCityIdentifier(
  cityIdentifier: string,
  serviceToken: string
) {
  const city = await getCityByCityIdentifier({
    cityIdentifier,
    lang: Lang.en,
    serviceToken,
  })

  if (!city) {
    return []
  }

  const hotelIds = await getHotelIdsByCityId({
    cityId: city.id,
    serviceToken,
  })
  return hotelIds
}

export async function getCityByCityIdentifier({
  cityIdentifier,
  lang,
  serviceToken,
}: {
  cityIdentifier: string
  lang: Lang
  serviceToken: string
}) {
  const locations = await getLocations({
    lang,
    citiesByCountry: null,
    serviceToken,
  })
  if (!locations || "error" in locations) {
    return null
  }

  const city = locations
    .filter((loc): loc is CityLocation => loc.type === "cities")
    .find((loc) => loc.cityIdentifier === cityIdentifier)

  return city ?? null
}

export async function getHotelsByHotelIds({
  hotelIds,
  lang,
  serviceToken,
}: {
  hotelIds: string[]
  lang: Lang
  serviceToken: string
}) {
  const cacheClient = await getCacheClient()
  const cacheKey = `${lang}:getHotelsByHotelIds:hotels:${hotelIds.sort().join(",")}`

  return await cacheClient.cacheOrGet(
    cacheKey,
    async () => {
      const hotelPages = await getHotelPageUrls(lang)
      const chunkedHotelIds = chunk(hotelIds, 10)

      const hotels: DestinationPagesHotelData[] = []
      for (const hotelIdChunk of chunkedHotelIds) {
        const chunkedHotels = await Promise.all(
          hotelIdChunk.map(async (hotelId) => {
            const hotelResponse = await getHotel(
              { hotelId, language: lang, isCardOnlyPayment: false },
              serviceToken
            )

            if (!hotelResponse) {
              throw new Error(`Hotel not found: ${hotelId}`)
            }

            const hotelPage = hotelPages.find(
              (page) => page.hotelId === hotelId
            )
            const { hotel, cities } = hotelResponse
            const data: DestinationPagesHotelData = {
              hotel: {
                id: hotel.id,
                galleryImages: hotel.galleryImages,
                name: hotel.name,
                tripadvisor: hotel.ratings?.tripAdvisor?.rating,
                detailedFacilities: hotel.detailedFacilities || [],
                location: hotel.location,
                hotelType: hotel.hotelType,
                type: hotel.type,
                address: hotel.address,
                cityIdentifier: cities?.[0]?.cityIdentifier,
                hotelDescription: hotel.hotelContent?.texts.descriptions?.short,
              },
              url: hotelPage?.url ?? "",
            }

            return data
          })
        )

        hotels.push(...chunkedHotels)
      }
      return hotels.filter(
        (hotel): hotel is DestinationPagesHotelData => !!hotel
      )
    },
    "1d"
  )
}

function findProduct(product: Products, rateDefinition: RateDefinition) {
  if ("corporateCheque" in product) {
    return product.corporateCheque.rateCode === rateDefinition.rateCode
  }

  if (("member" in product && product.member) || "public" in product) {
    let isMemberRate = false
    if (product.member) {
      isMemberRate = product.member.rateCode === rateDefinition.rateCode
    }
    let isPublicRate = false
    if (product.public) {
      isPublicRate = product.public.rateCode === rateDefinition.rateCode
    }
    return isMemberRate || isPublicRate
  }

  if ("voucher" in product) {
    return product.voucher.rateCode === rateDefinition.rateCode
  }

  if (Array.isArray(product)) {
    return product.find(
      (r) => r.redemption.rateCode === rateDefinition.rateCode
    )
  }
}

export const getHotel = cache(
  async (input: HotelInput, serviceToken: string) => {
    const { hotelId, language, isCardOnlyPayment } = input

    const getHotelCounter = createCounter("hotel", "getHotel")
    const metricsGetHotel = getHotelCounter.init({
      hotelId,
      language,
      isCardOnlyPayment,
    })

    metricsGetHotel.start()

    const cacheClient = await getCacheClient()

    const result = await cacheClient.cacheOrGet(
      `${input.language}:hotel:${input.hotelId}:${!!input.isCardOnlyPayment}`,
      async () => {
        /**
         * Since API expects the params appended and not just
         * a comma separated string we need to initialize the
         * SearchParams with a sequence of pairs
         * (include=City&include=NearbyHotels&include=Restaurants etc.)
         **/
        const params = new URLSearchParams([
          ["include", "AdditionalData"],
          ["include", "City"],
          ["include", "NearbyHotels"],
          ["include", "Restaurants"],
          ["include", "RoomCategories"],
          ["language", toApiLang(language)],
        ])

        const apiResponse = await api.get(
          api.endpoints.v1.Hotel.Hotels.hotel(hotelId),
          {
            headers: {
              Authorization: `Bearer ${serviceToken}`,
            },
          },
          params
        )

        if (!apiResponse.ok) {
          await metricsGetHotel.httpError(apiResponse)
          return null
        }

        const apiJson = await apiResponse.json()
        const validateHotelData = hotelSchema.safeParse(apiJson)

        if (!validateHotelData.success) {
          metricsGetHotel.validationError(validateHotelData.error)
          throw badRequestError()
        }

        const hotelData = validateHotelData.data

        if (isCardOnlyPayment) {
          hotelData.hotel.merchantInformationData.alternatePaymentOptions = []
        }

        const gallery = hotelData.additionalData?.gallery
        if (gallery) {
          const smallerImages = gallery.smallerImages
          const hotelGalleryImages =
            hotelData.hotel.hotelType === HotelTypeEnum.Signature
              ? smallerImages.slice(0, 10)
              : smallerImages.slice(0, 6)
          hotelData.hotel.galleryImages = hotelGalleryImages
        }

        return hotelData
      },
      env.CACHE_TIME_HOTELS
    )

    metricsGetHotel.success()

    return result
  }
)

export async function getHotelsAvailabilityByCity(
  input: HotelsAvailabilityInputSchema,
  apiLang: string,
  token: string, // Either service token or user access token in case of redemption search
  userPoints: number = 0
) {
  const {
    cityId,
    roomStayStartDate,
    roomStayEndDate,
    adults,
    children,
    bookingCode,
    redemption,
  } = input

  const params: Record<string, string | number> = {
    roomStayStartDate,
    roomStayEndDate,
    adults,
    ...(children && { children }),
    ...(bookingCode && { bookingCode }),
    ...(redemption ? { isRedemption: "true" } : {}),
    language: apiLang,
  }

  const getHotelsAvailabilityByCityCounter = createCounter(
    "hotel",
    "getHotelsAvailabilityByCity"
  )
  const metricsGetHotelsAvailabilityByCity =
    getHotelsAvailabilityByCityCounter.init({
      apiLang,
      cityId,
      roomStayStartDate,
      roomStayEndDate,
      adults,
      children,
      bookingCode,
      redemption,
    })

  metricsGetHotelsAvailabilityByCity.start()

  const apiResponse = await api.get(
    api.endpoints.v1.Availability.city(cityId),
    {
      headers: {
        Authorization: `Bearer ${token}`,
      },
    },
    params
  )
  if (!apiResponse.ok) {
    await metricsGetHotelsAvailabilityByCity.httpError(apiResponse)
    throw new Error("Failed to fetch hotels availability by city")
  }

  const apiJson = await apiResponse.json()
  const validateAvailabilityData = hotelsAvailabilitySchema.safeParse(apiJson)
  if (!validateAvailabilityData.success) {
    metricsGetHotelsAvailabilityByCity.validationError(
      validateAvailabilityData.error
    )
    throw badRequestError()
  }

  if (redemption) {
    validateAvailabilityData.data.data.forEach((data) => {
      data.attributes.productType?.redemptions?.forEach((r) => {
        r.hasEnoughPoints = userPoints >= r.localPrice.pointsPerStay
      })
    })
  }

  const result = {
    availability: validateAvailabilityData.data.data.flatMap(
      (hotels) => hotels.attributes
    ),
  }

  metricsGetHotelsAvailabilityByCity.success()

  return result
}

export async function getHotelsAvailabilityByHotelIds(
  input: HotelsByHotelIdsAvailabilityInputSchema,
  apiLang: string,
  serviceToken: string
) {
  const {
    hotelIds,
    roomStayStartDate,
    roomStayEndDate,
    adults,
    children,
    bookingCode,
  } = input

  const params = new URLSearchParams([
    ["roomStayStartDate", roomStayStartDate],
    ["roomStayEndDate", roomStayEndDate],
    ["adults", adults.toString()],
    ["children", children ?? ""],
    ["bookingCode", bookingCode],
    ["language", apiLang],
  ])

  const getHotelsAvailabilityByHotelIdsCounter = createCounter(
    "hotel",
    "getHotelsAvailabilityByHotelIds"
  )
  const metricsGetHotelsAvailabilityByHotelIds =
    getHotelsAvailabilityByHotelIdsCounter.init({
      apiLang,
      hotelIds,
      roomStayStartDate,
      roomStayEndDate,
      adults,
      children,
      bookingCode,
    })

  metricsGetHotelsAvailabilityByHotelIds.start()

  const cacheClient = await getCacheClient()

  const result = cacheClient.cacheOrGet(
    `${apiLang}:hotels:availability:${hotelIds.join(",")}:${roomStayStartDate}:${roomStayEndDate}:${adults}:${children}:${bookingCode}`,
    async () => {
      /**
       * Since API expects the params appended and not just
       * a comma separated string we need to initialize the
       * SearchParams with a sequence of pairs
       * (hotelIds=810&hotelIds=879&hotelIds=222 etc.)
       **/

      hotelIds.forEach((hotelId) =>
        params.append("hotelIds", hotelId.toString())
      )

      const apiResponse = await api.get(
        api.endpoints.v1.Availability.hotels(),
        {
          headers: {
            Authorization: `Bearer ${serviceToken}`,
          },
        },
        params
      )
      if (!apiResponse.ok) {
        await metricsGetHotelsAvailabilityByHotelIds.httpError(apiResponse)
        throw new Error("Failed to fetch hotels availability by hotelIds")
      }

      const apiJson = await apiResponse.json()
      const validateAvailabilityData =
        hotelsAvailabilitySchema.safeParse(apiJson)
      if (!validateAvailabilityData.success) {
        metricsGetHotelsAvailabilityByHotelIds.validationError(
          validateAvailabilityData.error
        )
        throw badRequestError()
      }

      return {
        availability: validateAvailabilityData.data.data.flatMap(
          (hotels) => hotels.attributes
        ),
      }
    },
    env.CACHE_TIME_CITY_SEARCH
  )

  metricsGetHotelsAvailabilityByHotelIds.success()

  return result
}

async function getRoomFeaturesInventory(
  input: RoomFeaturesInput,
  token: string
) {
  const {
    adults,
    childrenInRoom,
    endDate,
    hotelId,
    roomFeatureCodes,
    startDate,
  } = input

  const params = {
    adults,
    hotelId,
    roomFeatureCode: roomFeatureCodes,
    roomStayEndDate: endDate,
    roomStayStartDate: startDate,
    ...(childrenInRoom?.length && {
      children: generateChildrenString(childrenInRoom),
    }),
  }

  const getRoomFeaturesInventoryCounter = createCounter(
    "hotel",
    "getRoomFeaturesInventory"
  )
  const metricsGetRoomFeaturesInventory =
    getRoomFeaturesInventoryCounter.init(params)

  metricsGetRoomFeaturesInventory.start()

  const cacheClient = await getCacheClient()

  const result = cacheClient.cacheOrGet(
    stringify(input),
    async function () {
      const apiResponse = await api.get(
        api.endpoints.v1.Availability.roomFeatures(hotelId),
        {
          headers: {
            Authorization: `Bearer ${token}`,
          },
        },
        params
      )

      if (!apiResponse.ok) {
        await metricsGetRoomFeaturesInventory.httpError(apiResponse)
        return null
      }

      const data = await apiResponse.json()
      const validatedRoomFeaturesData = roomFeaturesSchema.safeParse(data)
      if (!validatedRoomFeaturesData.success) {
        metricsGetRoomFeaturesInventory.validationError(
          validatedRoomFeaturesData.error
        )
        return null
      }

      return validatedRoomFeaturesData.data
    },
    "5m"
  )

  metricsGetRoomFeaturesInventory.success()

  return result
}

export async function getPackages(input: PackagesOutput, serviceToken: string) {
  const { adults, children, endDate, hotelId, lang, packageCodes, startDate } =
    input

  const getPackagesCounter = createCounter("hotel", "getPackages")
  const metricsGetPackages = getPackagesCounter.init({
    input,
  })

  metricsGetPackages.start()

  const cacheClient = await getCacheClient()

  const result = cacheClient.cacheOrGet(
    stringify(input),
    async function () {
      const apiLang = toApiLang(lang)

      const searchParams = new URLSearchParams({
        adults: adults.toString(),
        children: children.toString(),
        endDate,
        language: apiLang,
        startDate,
      })

      packageCodes.forEach((code) => {
        searchParams.append("packageCodes", code)
      })

      const apiResponse = await api.get(
        api.endpoints.v1.Package.Packages.hotel(hotelId),
        {
          headers: {
            Authorization: `Bearer ${serviceToken}`,
          },
        },
        searchParams
      )

      if (!apiResponse.ok) {
        await metricsGetPackages.httpError(apiResponse)
        return null
      }

      const apiJson = await apiResponse.json()
      const validatedPackagesData = packagesSchema.safeParse(apiJson)
      if (!validatedPackagesData.success) {
        metricsGetPackages.validationError(validatedPackagesData.error)
        return null
      }

      return validatedPackagesData.data
    },
    "3h"
  )

  metricsGetPackages.success()

  return result
}

export async function getRoomsAvailability(
  input: RoomsAvailabilityOutputSchema,
  token: string,
  serviceToken: string,
  userPoints: number | undefined
) {
  const {
    booking: { bookingCode, fromDate, hotelId, rooms, searchType, toDate },
    lang,
  } = input

  const redemption = searchType === REDEMPTION

  const getRoomsAvailabilityCounter = createCounter(
    "hotel",
    "getRoomsAvailability"
  )
  const metricsGetRoomsAvailability = getRoomsAvailabilityCounter.init({
    input,
    redemption,
  })

  metricsGetRoomsAvailability.start()

  const apiLang = toApiLang(lang)

  const baseCacheKey = {
    bookingCode,
    fromDate,
    hotelId,
    lang,
    searchType,
    toDate,
  }

  const cacheClient = await getCacheClient()
  const availabilityResponses = await Promise.allSettled(
    rooms.map((room: RoomsAvailabilityInputRoom) => {
      const cacheKey = {
        ...baseCacheKey,
        room,
      }
      const result = cacheClient.cacheOrGet(
        stringify(cacheKey),
        async function () {
          {
            const params = {
              adults: room.adults,
              language: apiLang,
              roomStayStartDate: fromDate,
              roomStayEndDate: toDate,
              ...(room.childrenInRoom?.length && {
                children: generateChildrenString(room.childrenInRoom),
              }),
              ...(room.bookingCode && { bookingCode: room.bookingCode }),
              ...(redemption && { isRedemption: "true" }),
            }

            const apiResponse = await api.get(
              api.endpoints.v1.Availability.hotel(hotelId),
              {
                cache: undefined, // overwrite default
                headers: {
                  Authorization: `Bearer ${token}`,
                },
              },
              params
            )

            if (!apiResponse.ok) {
              await metricsGetRoomsAvailability.httpError(apiResponse)
              const text = await apiResponse.text()
              return { error: "http_error", details: text }
            }

            const apiJson = await apiResponse.json()
            const validateAvailabilityData =
              roomsAvailabilitySchema.safeParse(apiJson)
            if (!validateAvailabilityData.success) {
              metricsGetRoomsAvailability.validationError(
                validateAvailabilityData.error
              )

              return {
                error: "validation_error",
                details: validateAvailabilityData.error,
              }
            }

            if (redemption) {
              for (const roomConfig of validateAvailabilityData.data
                .roomConfigurations) {
                for (const product of roomConfig.redemptions) {
                  if (userPoints) {
                    product.redemption.hasEnoughPoints =
                      userPoints >= product.redemption.localPrice.pointsPerStay
                  }
                }
              }
            }

            const roomFeatures = await getPackages(
              {
                adults: room.adults,
                children: room.childrenInRoom?.length || 0,
                endDate: input.booking.toDate,
                hotelId: input.booking.hotelId,
                lang,
                packageCodes: [
                  RoomPackageCodeEnum.ACCESSIBILITY_ROOM,
                  RoomPackageCodeEnum.ALLERGY_ROOM,
                  RoomPackageCodeEnum.PET_ROOM,
                ],
                startDate: input.booking.fromDate,
              },
              serviceToken
            )

            if (roomFeatures) {
              validateAvailabilityData.data.packages = roomFeatures
            }

            // Fetch packages
            if (room.packages?.length) {
              const roomFeaturesInventory = await getRoomFeaturesInventory(
                {
                  adults: room.adults,
                  childrenInRoom: room.childrenInRoom,
                  endDate: input.booking.toDate,
                  hotelId: input.booking.hotelId,
                  lang,
                  roomFeatureCodes: room.packages,
                  startDate: input.booking.fromDate,
                },
                serviceToken
              )

              if (roomFeaturesInventory) {
                const features = roomFeaturesInventory.reduce<
                  Record<string, number>
                >((fts, feat) => {
                  fts[feat.roomTypeCode] = feat.features?.[0]?.inventory ?? 0
                  return fts
                }, {})

                const updatedRoomConfigurations =
                  validateAvailabilityData.data.roomConfigurations
                    // This filter is needed since we can get availability
                    // back from roomFeatures yet the availability call
                    // says there are no rooms left...
                    .filter((rc) => rc.roomsLeft)
                    .filter((rc) => features?.[rc.roomTypeCode])
                    .map((rc) => ({
                      ...rc,
                      roomsLeft: features[rc.roomTypeCode],
                      status: AvailabilityEnum.Available,
                    }))

                validateAvailabilityData.data.roomConfigurations =
                  updatedRoomConfigurations
              }
            }

            return validateAvailabilityData.data
          }
        },
        "1m"
      )

      return result
    })
  )

  const data = availabilityResponses.map((availability) => {
    if (availability.status === "fulfilled") {
      return availability.value
    }
    return {
      details: availability.reason,
      error: "request_failure",
    }
  })

  metricsGetRoomsAvailability.success()

  return data
}

export function getSelectedRoomAvailability(
  rateCode: string,
  rateDefinitions: RateDefinition[],
  roomConfigurations: RoomConfiguration[],
  roomTypeCode: string,
  userPoints: number | undefined
) {
  const rateDefinition = rateDefinitions.find((rd) => rd.rateCode === rateCode)
  if (!rateDefinition) {
    return null
  }

  const selectedRoom = roomConfigurations.find(
    (room) =>
      room.roomTypeCode === roomTypeCode &&
      room.products.find((product) => findProduct(product, rateDefinition))
  )

  if (!selectedRoom) {
    return null
  }

  let product: Product | RedemptionsProduct | undefined =
    selectedRoom.products.find((product) =>
      findProduct(product, rateDefinition)
    )

  if (!product) {
    return null
  }

  if (Array.isArray(product)) {
    const redemptionProduct = userPoints
      ? product.find(
          (r) =>
            r.redemption.rateCode === rateDefinition.rateCode &&
            r.redemption.localPrice.pointsPerStay <= userPoints
        )
      : undefined
    if (!redemptionProduct) {
      return null
    }
    product = redemptionProduct
  }

  return {
    rateDefinition,
    rateDefinitions,
    rooms: roomConfigurations,
    product,
    selectedRoom,
  }
}

export function getBedTypes(
  rooms: RoomConfiguration[],
  roomType: string,
  roomCategories?: RoomCategory[]
) {
  if (!roomCategories) {
    return []
  }

  return rooms
    .filter(
      (room) => room.status === AvailabilityEnum.Available || room.roomsLeft > 0
    )
    .filter((room) => room.roomType === roomType)
    .map((availRoom) => {
      const matchingRoom = roomCategories
        ?.find((room) =>
          room.roomTypes
            .map((roomType) => roomType.code)
            .includes(availRoom.roomTypeCode)
        )
        ?.roomTypes.find((roomType) => roomType.code === availRoom.roomTypeCode)

      if (matchingRoom) {
        return {
          description: matchingRoom.description,
          size: matchingRoom.mainBed.widthRange,
          value: matchingRoom.code,
          type: matchingRoom.mainBed.type,
          roomsLeft: availRoom.roomsLeft,
          extraBed: matchingRoom.fixedExtraBed
            ? {
                type: matchingRoom.fixedExtraBed.type,
                description: matchingRoom.fixedExtraBed.description,
              }
            : undefined,
        }
      }
    })
    .filter((bed): bed is BedTypeSelection => Boolean(bed))
}

export function mergeRoomTypes(roomConfigurations: RoomConfiguration[]) {
  // Initial sort to guarantee if one bed is NotAvailable and whereas
  // the other is Available to make sure data is added to the correct
  // roomConfig
  roomConfigurations.sort(sortRoomConfigs)

  const roomConfigs = new Map<string, RoomConfiguration>()
  for (const roomConfig of roomConfigurations) {
    if (roomConfigs.has(roomConfig.roomType)) {
      const currentRoomConf = roomConfigs.get(roomConfig.roomType)
      if (currentRoomConf) {
        currentRoomConf.features = roomConfig.features.reduce(
          (feats, feature) => {
            const currentFeatureIndex = feats.findIndex(
              (f) => f.code === feature.code
            )
            if (currentFeatureIndex !== -1) {
              feats[currentFeatureIndex].inventory =
                feats[currentFeatureIndex].inventory + feature.inventory
            } else {
              feats.push(feature)
            }
            return feats
          },
          currentRoomConf.features
        )
        currentRoomConf.roomsLeft =
          currentRoomConf.roomsLeft + roomConfig.roomsLeft
        roomConfigs.set(currentRoomConf.roomType, currentRoomConf)
      }
    } else {
      roomConfigs.set(roomConfig.roomType, roomConfig)
    }
  }
  return Array.from(roomConfigs.values())
}

export function selectRateRedirectURL(
  input: RoomsAvailabilityExtendedInputSchema,
  selectedRooms: boolean[]
) {
  const searchParams = new URLSearchParams({
    errorCode: BookingErrorCodeEnum.AvailabilityError,
    fromdate: input.booking.fromDate,
    hotel: input.booking.hotelId,
    todate: input.booking.toDate,
  })
  if (input.booking.searchType) {
    searchParams.set("searchtype", input.booking.searchType)
  }
  for (const [idx, room] of input.booking.rooms.entries()) {
    searchParams.set(`room[${idx}].adults`, room.adults.toString())

    if (selectedRooms[idx]) {
      if (room.counterRateCode) {
        searchParams.set(`room[${idx}].counterratecode`, room.counterRateCode)
      }
      searchParams.set(`room[${idx}].ratecode`, room.rateCode)
      searchParams.set(`room[${idx}].roomtype`, room.roomTypeCode)
    }
    if (room.bookingCode) {
      searchParams.set(`room[${idx}].bookingCode`, room.bookingCode)
    }
    if (room.packages) {
      searchParams.set(`room[${idx}].packages`, room.packages.join(","))
    }
    if (room.childrenInRoom?.length) {
      for (const [i, kid] of room.childrenInRoom.entries()) {
        searchParams.set(`room[${idx}].child[${i}].age`, kid.age.toString())
        searchParams.set(`room[${idx}].child[${i}].bed`, kid.bed.toString())
      }
    }
  }

  return `${selectRate(input.lang)}?${searchParams.toString()}`
}
