import {type SanityUser as SanityUserFromClient} from '@sanity/client'
import {createSelector} from 'reselect'
import {
  catchError,
  combineLatest,
  distinctUntilChanged,
  EMPTY,
  filter,
  first,
  firstValueFrom,
  groupBy,
  map,
  mergeMap,
  NEVER,
  Observable,
  of,
  pairwise,
  race,
  skip,
  startWith,
  switchMap,
  tap,
  throwError,
  withLatestFrom,
} from 'rxjs'

import {getDashboardOrganizationId} from '../auth/authStore'
import {getClient, getClientState} from '../client/clientStore'
import {bindActionGlobally} from '../store/createActionBinder'
import {createStateSourceAction, type SelectorContext} from '../store/createStateSourceAction'
import {type StoreState} from '../store/createStoreState'
import {defineStore, type StoreContext} from '../store/defineStore'
import {insecureRandomId} from '../utils/ids'
import {
  addSubscription,
  cancelRequest,
  getUsersKey,
  initializeRequest,
  parseUsersKey,
  removeSubscription,
  setUsersData,
  setUsersError,
  updateLastLoadMoreRequest,
} from './reducers'
import {
  type GetUserOptions,
  type GetUsersOptions,
  type ResolveUserOptions,
  type ResolveUsersOptions,
  type SanityUser,
  type SanityUserResponse,
  type UsersStoreState,
} from './types'
import {API_VERSION, PROJECT_API_VERSION, USERS_STATE_CLEAR_DELAY} from './usersConstants'

/** @internal */
export type PatchedSanityUserFromClient = Omit<SanityUserFromClient, 'id'> & {
  id: string
  sanityUserId: string
  email: string
  provider: string
}

/**
 * The users store resource that manages user data fetching and state.
 *
 * This store handles fetching, caching, and managing user data. It provides functionality for
 * retrieving users associated with specific resources and supports pagination through the
 * `loadMoreUsers` action.
 *
 * @internal
 */
const usersStore = defineStore<UsersStoreState>({
  name: 'UsersStore',
  getInitialState: () => ({users: {}}),
  initialize: (context) => {
    const subscription = listenForLoadMoreAndFetch(context)
    return () => subscription.unsubscribe()
  },
})

const errorHandler =
  (state: StoreState<{error?: unknown}>) =>
  (error: unknown): void =>
    state.set('setError', {error})

/**
 * Internal action that listens for new user subscriptions and load more requests.
 * Fetches user data when new subscriptions are added or when loadMoreUsers is called.
 */
const listenForLoadMoreAndFetch = ({state, instance}: StoreContext<UsersStoreState>) => {
  return state.observable
    .pipe(
      map((s) => new Set(Object.keys(s.users))),
      distinctUntilChanged((curr, next) => {
        if (curr.size !== next.size) return false
        return Array.from(next).every((i) => curr.has(i))
      }),
      startWith(new Set<string>()),
      pairwise(),
      mergeMap(([curr, next]) => {
        const added = Array.from(next).filter((i) => !curr.has(i))
        const removed = Array.from(curr).filter((i) => !next.has(i))

        return [
          ...added.map((key) => ({key, added: true})),
          ...removed.map((key) => ({key, added: false})),
        ]
      }),
      groupBy((i) => i.key),
      mergeMap((group$) =>
        group$.pipe(
          switchMap((e) => {
            if (!e.added) return EMPTY
            const {userId, batchSize, ...options} = parseUsersKey(group$.key)

            if (userId) {
              // In the future we will be able to fetch a user from the global API using the resourceUserId,
              // but for now we need to use the project subdomain to fetch a user if the userId is a project user (starts with "p")
              if (userId.startsWith('p')) {
                const client = getClient(instance, {
                  apiVersion: PROJECT_API_VERSION,
                  // this is a global store, so we need to use the projectId from the options when we're fetching
                  // users from a project subdomain
                  projectId: options.projectId,
                  useProjectHostname: true,
                })

                return client.observable
                  .request<PatchedSanityUserFromClient>({
                    method: 'GET',
                    uri: `/users/${userId}`,
                  })
                  .pipe(
                    map((user) => {
                      // We need to convert the user to the format we expect
                      const convertedUser: SanityUser = {
                        sanityUserId: user.sanityUserId,
                        profile: {
                          id: user.id,
                          displayName: user.displayName,
                          familyName: user.familyName ?? undefined,
                          givenName: user.givenName ?? undefined,
                          middleName: user.middleName ?? undefined,
                          imageUrl: user.imageUrl ?? undefined,
                          createdAt: user.createdAt,
                          updatedAt: user.updatedAt,
                          isCurrentUser: user.isCurrentUser,
                          email: user.email,
                          provider: user.provider,
                        },
                        memberships: [],
                      }
                      return {
                        data: [convertedUser],
                        totalCount: 1,
                        nextCursor: null,
                      }
                    }),
                    catchError((error) => {
                      state.set('setUsersError', setUsersError(group$.key, error))
                      return EMPTY
                    }),
                    tap((response) =>
                      state.set('setUsersData', setUsersData(group$.key, response)),
                    ),
                  )
              }

              // Fetch the user from the global API
              const scope = userId.startsWith('g') ? 'global' : undefined
              const client = getClient(instance, {
                scope,
                apiVersion: API_VERSION,
              })
              const resourceType = options.resourceType || 'project'
              const resourceId =
                resourceType === 'organization' ? options.organizationId : options.projectId
              if (!resourceId) {
                return throwError(() => new Error('An organizationId or a projectId is required'))
              }
              return client.observable
                .request<SanityUser | SanityUserResponse>({
                  method: 'GET',
                  uri: `access/${resourceType}/${resourceId}/users/${userId}`,
                })
                .pipe(
                  map((response) => {
                    // If the response is a single user object (has sanityUserId), wrap it in the expected format
                    if ('sanityUserId' in response) {
                      return {
                        data: [response],
                        totalCount: 1,
                        nextCursor: null,
                      } as SanityUserResponse
                    }
                    return response as SanityUserResponse
                  }),
                  catchError((error) => {
                    state.set('setUsersError', setUsersError(group$.key, error))
                    return EMPTY
                  }),
                  tap((response) => state.set('setUsersData', setUsersData(group$.key, response))),
                )
            }
            const projectId = options.projectId

            // the resource type this request will use
            // If resourceType is explicitly provided, use it
            // Otherwise, infer from context: organization if organizationId exists,
            // project if projectId exists, or default to organization
            const resourceType =
              options.resourceType ??
              (options.organizationId ? 'organization' : projectId ? 'project' : 'organization')

            const organizationId$ = options.organizationId
              ? of(options.organizationId)
              : getDashboardOrganizationId(instance).observable.pipe(
                  filter((i) => typeof i === 'string'),
                )

            const resource$: Observable<{
              type: 'project' | 'organization'
              id: string
            }> =
              resourceType === 'project'
                ? projectId
                  ? of({type: 'project', id: projectId})
                  : throwError(() => new Error('Project ID required for this API.'))
                : organizationId$.pipe(map((id) => ({type: 'organization', id})))

            const client$ = getClientState(instance, {
              scope: 'global',
              apiVersion: API_VERSION,
            }).observable

            const loadMore$ = state.observable.pipe(
              map((s) => s.users[group$.key]?.lastLoadMoreRequest),
              distinctUntilChanged(),
            )

            const cursor$ = state.observable.pipe(
              map((s) => s.users[group$.key]?.nextCursor),
              distinctUntilChanged(),
              filter((cursor) => cursor !== null),
            )

            return combineLatest([resource$, client$, loadMore$]).pipe(
              withLatestFrom(cursor$),
              switchMap(([[resource, client], cursor]) =>
                client.observable.request<SanityUserResponse>({
                  method: 'GET',
                  uri: `access/${resource.type}/${resource.id}/users`,
                  query: cursor
                    ? {nextCursor: cursor, limit: batchSize.toString()}
                    : {limit: batchSize.toString()},
                }),
              ),
              catchError((error) => {
                state.set('setUsersError', setUsersError(group$.key, error))
                return EMPTY
              }),
              tap((response) => state.set('setUsersData', setUsersData(group$.key, response))),
            )
          }),
        ),
      ),
    )
    .subscribe({error: errorHandler(state)})
}

/**
 * Returns the state source for users associated with a specific resource.
 *
 * This function returns a state source that represents the current list of users for a given
 * resource. Subscribing to the state source will instruct the SDK to fetch the users (if not
 * already fetched) and will load more from this state source as well. When the last subscriber is
 * removed, the users state is automatically cleaned up from the store after a delay.
 *
 * Note: This functionality is for advanced users who want to build their own framework
 * integrations. Our SDK also provides a React integration for convenient usage.
 *
 * @beta
 */
export const getUsersState = bindActionGlobally(
  usersStore,
  createStateSourceAction({
    selector: createSelector(
      [
        ({instance, state}: SelectorContext<UsersStoreState>, options?: GetUsersOptions) =>
          state.error ?? state.users[getUsersKey(instance, options)]?.error,
        ({instance, state}: SelectorContext<UsersStoreState>, options: GetUsersOptions) =>
          state.users[getUsersKey(instance, options)]?.users,
        ({instance, state}: SelectorContext<UsersStoreState>, options: GetUsersOptions) =>
          state.users[getUsersKey(instance, options)]?.totalCount,
        ({instance, state}: SelectorContext<UsersStoreState>, options: GetUsersOptions) =>
          state.users[getUsersKey(instance, options)]?.nextCursor,
      ],
      (error, data, totalCount, nextCursor) => {
        if (error) throw error
        if (data === undefined || totalCount === undefined || nextCursor === undefined) {
          return undefined
        }

        return {data, totalCount, hasMore: nextCursor !== null}
      },
    ),
    onSubscribe: ({instance, state}, options?: GetUsersOptions) => {
      const subscriptionId = insecureRandomId()
      const key = getUsersKey(instance, options)
      state.set('addSubscription', addSubscription(subscriptionId, key))
      return () => {
        setTimeout(
          () => state.set('removeSubscription', removeSubscription(subscriptionId, key)),
          USERS_STATE_CLEAR_DELAY,
        )
      }
    },
  }),
)

/**
 * Resolves the users for a specific resource without registering a lasting subscriber.
 *
 * This function fetches the users for a given resource and returns a promise that resolves with
 * the users result. Unlike `getUsersState`, which registers subscribers to keep the data live and
 * performs automatic cleanup, `resolveUsers` does not track subscribers. This makes it ideal for
 * use with React Suspense, where the returned promise is thrown to delay rendering until the users
 * result becomes available. Once the promise resolves, it is expected that a real subscriber will
 * be added via `getUsersState` to manage ongoing updates.
 *
 * Additionally, an optional AbortSignal can be provided to cancel the request and immediately
 * clear the associated state if there are no active subscribers.
 *
 * @beta
 */
export const resolveUsers = bindActionGlobally(
  usersStore,
  async ({state, instance}, {signal, ...options}: ResolveUsersOptions) => {
    const key = getUsersKey(instance, options)
    const {getCurrent} = getUsersState(instance, options)

    const aborted$ = signal
      ? new Observable<never>((observer) => {
          const cleanup = () => {
            signal.removeEventListener('abort', listener)
          }

          const listener = () => {
            observer.error(new DOMException('The operation was aborted.', 'AbortError'))
            observer.complete()
            cleanup()
          }
          signal.addEventListener('abort', listener)

          return cleanup
        }).pipe(
          catchError((error) => {
            if (error instanceof Error && error.name === 'AbortError') {
              state.set('cancelRequest', cancelRequest(key))
            }
            throw error
          }),
        )
      : NEVER

    state.set('initializeRequest', initializeRequest(key))

    const resolved$ = state.observable.pipe(
      map(getCurrent),
      first((i) => i !== undefined),
    )

    return firstValueFrom(race([resolved$, aborted$]))
  },
)

/**
 * Loads more users for a specific resource.
 *
 * This function triggers a request to fetch the next page of users for a given resource. It
 * requires that users have already been loaded for the resource (via `resolveUsers` or
 * `getUsersState`), and that there are more users available to load (as indicated by the `hasMore`
 * property).
 *
 * The function returns a promise that resolves when the next page of users has been loaded.
 *
 * @beta
 */
export const loadMoreUsers = bindActionGlobally(
  usersStore,
  async ({state, instance}, options?: GetUsersOptions) => {
    const key = getUsersKey(instance, options)
    const users = getUsersState(instance, options)
    const usersState = users.getCurrent()
    if (!usersState) {
      throw new Error('Users not loaded for specified resource. Please call resolveUsers first.')
    }

    if (!usersState.hasMore) {
      throw new Error('No more users available to load for this resource.')
    }

    const promise = firstValueFrom(
      users.observable.pipe(
        filter((i) => i !== undefined),
        skip(1),
      ),
    )

    const timestamp = new Date().toISOString()
    state.set('updateLastLoadMoreRequest', updateLastLoadMoreRequest(timestamp, key))

    return await promise
  },
)

/**
 * @beta
 */
export const getUserState = bindActionGlobally(
  usersStore,
  ({instance}, {userId, ...options}: GetUserOptions) => {
    return getUsersState(instance, {userId, ...options}).observable.pipe(
      map((res) => res?.data[0]),
      distinctUntilChanged((a, b) => a?.profile.updatedAt === b?.profile.updatedAt),
    )
  },
)

/**
 * @beta
 */
export const resolveUser = bindActionGlobally(
  usersStore,
  async ({instance}, {signal, ...options}: ResolveUserOptions) => {
    const result = await resolveUsers(instance, {
      signal,
      ...options,
    })
    return result?.data[0]
  },
)
