---
import { z } from 'astro:schema'
import { groupBy, omit, orderBy, sortBy, uniq } from 'lodash-es'
import seedrandom from 'seedrandom'

import Button from '../components/Button.astro'
import InputText from '../components/InputText.astro'
import Pagination from '../components/Pagination.astro'
import ServiceFiltersPillsRow from '../components/ServiceFiltersPillsRow.astro'
import ServicesFilters from '../components/ServicesFilters.astro'
import ServicesSearchResults from '../components/ServicesSearchResults.astro'
import { currenciesZodEnumBySlug, currencySlugToId } from '../constants/currencies'
import { networks } from '../constants/networks'
import {
  verificationStatuses,
  verificationStatusesZodEnumBySlug,
  verificationStatusSlugToId,
} from '../constants/verificationStatus'
import BaseLayout from '../layouts/BaseLayout.astro'
import { areEqualArraysWithoutOrder, zodEnumFromConstant } from '../lib/arrays'
import { findServicesBySimilarity } from '../lib/findServicesBySimilarity'
import { parseIntWithFallback } from '../lib/numbers'
import { areEqualObjectsWithoutOrder } from '../lib/objects'
import { zodParseQueryParamsStoringErrors } from '../lib/parseUrlFilters'
import { prisma } from '../lib/prisma'
import {
  defaultSortOption,
  makeSearchFiltersOptions,
  modeOptions,
  sortOptions,
} from '../lib/searchFiltersOptions'
import { makeSortSeed } from '../lib/sortSeed'

import type { Prisma } from '@prisma/client'

const PAGE_SIZE = 30

export type AttributeOption = {
  value: string
  prefix: string
  prefixWith: string
}

const attributeOptions = [
  {
    value: 'yes',
    prefix: 'Has',
    prefixWith: 'with',
  },
  {
    value: 'no',
    prefix: 'Not',
    prefixWith: 'without',
  },
  {
    value: '',
    prefix: '',
    prefixWith: '',
  },
] as const satisfies AttributeOption[]

const ignoredKeysForDefaultData = ['sort-seed']

const {
  data: filters,
  hasDefaultData: hasDefaultFilters,
  defaultData: defaultFilters,
  redirectUrl,
} = zodParseQueryParamsStoringErrors(
  {
    categories: z.array(z.string()),
    verification: z
      .array(verificationStatusesZodEnumBySlug.transform((slug) => verificationStatusSlugToId(slug)))
      .default(
        verificationStatuses
          .filter((verification) => verification.default)
          .map((verification) => verification.slug)
      ),
    'min-score': z.coerce.number().default(0),
    'user-rating': z.coerce.number().int().min(0).max(5).default(0),
    q: z.string().default(''),
    currencies: z.array(currenciesZodEnumBySlug.transform((slug) => currencySlugToId(slug))),
    'currency-mode': zodEnumFromConstant(modeOptions, 'value').default('or'),
    'attribute-mode': zodEnumFromConstant(modeOptions, 'value').default('or'),
    sort: zodEnumFromConstant(sortOptions, 'value').default(defaultSortOption.value),
    'sort-seed': z
      .string()
      .transform((seed) => (Astro.url.searchParams.has('page') ? seed : makeSortSeed()))
      .default(makeSortSeed()),
    'max-kyc': z.coerce.number().int().min(0).max(4).default(4),
    networks: z.array(zodEnumFromConstant(networks, 'slug')),
    attr: z
      .record(z.coerce.number().int().positive(), zodEnumFromConstant(attributeOptions, 'value'))
      .optional(),
    page: z.coerce.number().int().min(1).default(1),
  },
  Astro,
  {
    ignoredKeysForDefaultData,
    cleanUrl: {
      removeUneededObjectParams: true,
      removeParams: {
        verification: { if: 'default' },
        q: { if: 'default' },
        sort: { if: 'default' },
        'currency-mode': { if: 'another-is-unset', prop: 'currencies' },
        'attribute-mode': { if: 'another-is-unset', prop: 'attr' },
        'min-score': { if: 'default' },
        'user-rating': { if: 'default' },
        'max-kyc': { if: 'default' },
        'sort-seed': { if: 'another-is-unset', prop: 'page' },
      },
    },
  }
)

const hasDefaultFiltersIgnoringQ = areEqualObjectsWithoutOrder(
  omit(filters, [...ignoredKeysForDefaultData, 'q']),
  omit(defaultFilters, [...ignoredKeysForDefaultData, 'q'])
)

if (redirectUrl) return Astro.redirect(redirectUrl.toString())

const includeScams =
  !!filters.q &&
  (areEqualArraysWithoutOrder(filters.verification, ['VERIFICATION_SUCCESS', 'APPROVED']) ||
    areEqualArraysWithoutOrder(filters.verification, [
      'VERIFICATION_SUCCESS',
      'APPROVED',
      'COMMUNITY_CONTRIBUTED',
    ]))

export type ServicesFiltersObject = typeof filters

const groupedAttributes = groupBy(
  Object.entries(filters.attr ?? {}).flatMap(([key, value]) => {
    const id = parseIntWithFallback(key)
    if (id === null) return []
    return [{ id, value }]
  }),
  'value'
)

const servicesQMatch = filters.q ? await findServicesBySimilarity(filters.q) : null

const where = {
  id: servicesQMatch ? { in: servicesQMatch.map(({ id }) => id) } : undefined,
  categories: filters.categories.length ? { some: { slug: { in: filters.categories } } } : undefined,
  verificationStatus: {
    in: includeScams ? uniq([...filters.verification, 'VERIFICATION_FAILED'] as const) : filters.verification,
  },
  serviceVisibility: { in: ['PUBLIC', 'ARCHIVED'] },
  overallScore: { gte: filters['min-score'] },
  acceptedCurrencies: filters.currencies.length
    ? filters['currency-mode'] === 'and'
      ? { hasEvery: filters.currencies }
      : { hasSome: filters.currencies }
    : undefined,
  kycLevel: {
    lte: filters['max-kyc'],
  },
  AND: [
    ...(filters['user-rating'] > 0
      ? [
          {
            averageUserRating: {
              gte: filters['user-rating'],
            },
          } satisfies Prisma.ServiceWhereInput,
        ]
      : []),
    ...(filters.networks.length
      ? [
          {
            OR: [
              ...(filters.networks.includes('onion') ? [{ onionUrls: { isEmpty: false } }] : []),
              ...(filters.networks.includes('i2p') ? [{ i2pUrls: { isEmpty: false } }] : []),
              ...(filters.networks.includes('clearnet') ? [{ serviceUrls: { isEmpty: false } }] : []),
            ],
          } satisfies Prisma.ServiceWhereInput,
        ]
      : []),
    ...(filters.attr && (groupedAttributes.yes?.length ?? 0) + (groupedAttributes.no?.length ?? 0) > 0
      ? [
          {
            AND: [
              ...(groupedAttributes.yes && groupedAttributes.yes.length > 0
                ? [
                    {
                      [filters['attribute-mode'] === 'and' ? 'AND' : 'OR']: groupedAttributes.yes.map(
                        ({ id }) =>
                          ({
                            attributes: {
                              some: {
                                attribute: {
                                  id,
                                },
                              },
                            },
                          }) satisfies Prisma.ServiceWhereInput
                      ),
                    },
                  ]
                : []),
              ...(groupedAttributes.no && groupedAttributes.no.length > 0
                ? [
                    {
                      [filters['attribute-mode'] === 'and' ? 'AND' : 'OR']: groupedAttributes.no.map(
                        ({ id }) =>
                          ({
                            attributes: {
                              none: {
                                attribute: {
                                  id,
                                },
                              },
                            },
                          }) satisfies Prisma.ServiceWhereInput
                      ),
                    },
                  ]
                : []),
            ],
          },
        ]
      : []),
  ],
} as const satisfies Prisma.ServiceWhereInput

const [categories, [services, totalServices], countCommunityOnly, attributes] =
  await Astro.locals.banners.tryMany([
    [
      'Unable to load category filters.',
      () =>
        prisma.category.findMany({
          select: {
            name: true,
            namePluralLong: true,
            slug: true,
            icon: true,
            _count: {
              select: {
                services: {
                  where: {
                    serviceVisibility: { in: ['PUBLIC', 'ARCHIVED'] },
                  },
                },
              },
            },
          },
        }),
      [],
    ],
    [
      'Unable to load services.',
      async () => {
        const [unsortedServicesMissingSimilarityScore, totalServices] = await prisma.service.findManyAndCount(
          {
            where,
            select: {
              id: true,
              serviceVisibility: true,
              verificationStatus: true,
              ...(Object.fromEntries(sortOptions.map((option) => [option.orderBy.key, true])) as Record<
                (typeof sortOptions)[number]['orderBy']['key'],
                true
              >),
            },
          }
        )

        const unsortedServices = unsortedServicesMissingSimilarityScore.map((service) => ({
          ...service,
          similarityScore: servicesQMatch?.find((match) => match.id === service.id)?.similarityScore ?? 0,
        }))

        const rng = seedrandom(filters['sort-seed'])
        const selectedSort = sortOptions.find((sort) => sort.value === filters.sort) ?? defaultSortOption

        const sortedServices = orderBy(
          // NOTE: We do a first sort by id to make the seeded sort deterministic
          sortBy(unsortedServices, 'id'),
          [
            ...(filters.q ? (['similarityScore'] as const) : ([] as const)),
            (service) => (service.verificationStatus === 'VERIFICATION_FAILED' ? 1 : 0),
            (service) => (service.serviceVisibility === 'ARCHIVED' ? 1 : 0),
            selectedSort.orderBy.key,
            () => rng(),
          ],
          [
            ...(filters.q ? (['desc'] as const) : ([] as const)),
            'asc',
            'asc',
            selectedSort.orderBy.direction,
            'asc',
          ]
        ).slice((filters.page - 1) * PAGE_SIZE, filters.page * PAGE_SIZE)

        const unsortedServicesWithInfoMissingSimilarityScore = await prisma.service.findMany({
          where: {
            id: {
              in: sortedServices.map((service) => service.id),
            },
          },
          select: {
            id: true,
            name: true,
            slug: true,
            description: true,
            overallScore: true,
            privacyScore: true,
            trustScore: true,
            kycLevel: true,
            imageUrl: true,
            verificationStatus: true,
            acceptedCurrencies: true,
            serviceVisibility: true,
            attributes: {
              select: {
                attribute: {
                  select: {
                    id: true,
                    slug: true,
                    title: true,
                    category: true,
                    type: true,
                  },
                },
              },
            },
            categories: {
              select: {
                name: true,
                icon: true,
              },
            },
          },
        })

        const unsortedServicesWithInfo = unsortedServicesWithInfoMissingSimilarityScore.map((service) => ({
          ...service,
          similarityScore: servicesQMatch?.find((match) => match.id === service.id)?.similarityScore ?? 0,
        }))

        const sortedServicesWithInfo = orderBy(
          unsortedServicesWithInfo,
          [
            ...(filters.q ? (['similarityScore'] as const) : ([] as const)),
            (service) => (service.verificationStatus === 'VERIFICATION_FAILED' ? 1 : 0),
            (service) => (service.serviceVisibility === 'ARCHIVED' ? 1 : 0),
            selectedSort.orderBy.key,
            // Now we can shuffle indeternimistically, because the pagination was already applied
            () => Math.random(),
          ],
          [
            ...(filters.q ? (['desc'] as const) : ([] as const)),
            'asc',
            'asc',
            selectedSort.orderBy.direction,
            'asc',
          ]
        )

        return [sortedServicesWithInfo, totalServices] as const
      },
      [[] as [], 0, false] as const,
    ],
    [
      'Unable to load count if including community.',
      () =>
        areEqualArraysWithoutOrder(filters.verification, ['VERIFICATION_SUCCESS', 'APPROVED']) ||
        areEqualArraysWithoutOrder(filters.verification, [
          'VERIFICATION_SUCCESS',
          'APPROVED',
          'VERIFICATION_FAILED',
        ])
          ? prisma.service.count({
              where: {
                ...where,
                verificationStatus: 'COMMUNITY_CONTRIBUTED',
              },
            })
          : null,
      null,
    ],
    [
      'Unable to load attribute filters.',
      () =>
        prisma.attribute.findMany({
          select: {
            id: true,
            slug: true,
            title: true,
            category: true,
            type: true,
            _count: {
              select: {
                services: true,
              },
            },
          },
          orderBy: [{ category: 'asc' }, { type: 'asc' }, { title: 'asc' }],
        }),
      [],
    ],
  ])

const filtersOptions = makeSearchFiltersOptions({
  filters,
  categories,
  attributes,
})

const searchResultsId = 'search-results'
const showFiltersId = 'show-filters'
---

<BaseLayout
  pageTitle="Find KYC-free Services"
  description="Find services that don't require KYC (Know Your Customer) verification for better privacy and control over your data."
  widthClassName="max-w-none"
  htmx
  showSplashText
  breadcrumbs={[
    {
      name: 'Services',
      url: '/',
    },
  ]}
>
  <div class="flex flex-col gap-4 sm:flex-row sm:gap-8">
    <div
      class='[&:has(~_#show-filters:focus-visible)_[for="show-filters"]]:ring-offset-night-700 flex items-stretch sm:hidden [&:has(~_#show-filters:focus-visible)_[for="show-filters"]]:ring-2 [&:has(~_#show-filters:focus-visible)_[for="show-filters"]]:ring-green-500 [&:has(~_#show-filters:focus-visible)_[for="show-filters"]]:ring-offset-2'
    >
      {
        hasDefaultFilters || hasDefaultFiltersIgnoringQ ? (
          <form
            method="GET"
            hx-get={Astro.url.pathname}
            hx-trigger="input delay:500ms, keyup[key=='Enter']"
            hx-target={`#${searchResultsId}`}
            hx-select={`#${searchResultsId}`}
            hx-swap="outerHTML"
            hx-push-url="true"
            hx-indicator="#search-indicator"
            class="contents"
          >
            <InputText
              name="q"
              label="Search..."
              hideLabel
              inputIcon="ri:search-line"
              inputIconClass="text-day-500 size-4.5"
              inputProps={{
                placeholder: 'Search',
                value: filters.q,
                class: 'bg-night-800 border-night-500',
                'transition:persist': false,
              }}
              class="mr-4 flex-1"
            />
          </form>
        ) : (
          <ServiceFiltersPillsRow
            filters={filters}
            filtersOptions={filtersOptions}
            categories={categories}
            attributes={attributes}
            attributeOptions={attributeOptions}
          />
        )
      }

      <Button
        as="label"
        for="show-filters"
        label="Filters"
        icon="ri:filter-3-line"
        class="max-2xs:w-9 max-2xs:px-0"
        classNames={{
          label: 'max-2xs:hidden',
        }}
      />
    </div>

    <input
      type="checkbox"
      id="show-filters"
      name="show-filters"
      class="peer sr-only sm:hidden"
      checked={Astro.url.searchParams.has('show-filters')}
    />
    <div
      class="bg-night-700 fixed top-0 left-0 z-50 hidden h-dvh w-dvw shrink-0 translate-y-full overflow-y-auto overscroll-contain border-t border-green-500/30 px-8 pt-4 transition-transform peer-checked:translate-y-0 max-sm:peer-checked:block sm:relative sm:z-auto sm:block sm:h-auto sm:w-64 sm:translate-y-0 sm:overflow-visible sm:border-none sm:bg-none sm:p-0"
    >
      <ServicesFilters
        searchResultsId={searchResultsId}
        showFiltersId={showFiltersId}
        filters={{
          ...filters,
          'sort-seed': makeSortSeed(),
        }}
        options={filtersOptions}
        hasDefaultFilters={hasDefaultFilters}
      />
    </div>

    <ServicesSearchResults
      services={services}
      id="search-results"
      currentPage={filters.page}
      total={totalServices}
      pageSize={PAGE_SIZE}
      sortSeed={filters['sort-seed']}
      filters={filters}
      countCommunityOnly={countCommunityOnly}
      inlineIcons
      categories={categories}
      filtersOptions={filtersOptions}
      attributes={attributes}
      attributeOptions={attributeOptions}
    />
  </div>
  {
    totalServices > PAGE_SIZE && (
      <Pagination
        currentPage={filters.page}
        totalPages={Math.ceil(totalServices / PAGE_SIZE)}
        sortSeed={filters['sort-seed']}
        class="js:hidden mt-8"
      />
    )
  }
</BaseLayout>

<script>
  ////////////////////////////////////////////////////////////
  // Optional script for removing sort-seed from URL.       //
  // This helps keep URLs cleaner when sharing.             //
  ////////////////////////////////////////////////////////////

  import { addOnLoadEventListener } from '../lib/onload'

  addOnLoadEventListener(() => {
    const url = new URL(window.location.href)
    if (url.searchParams.has('sort-seed')) {
      url.searchParams.delete('sort-seed')
      window.history.replaceState({}, '', url)
    }
  })
</script>
