import {
  dehydrate,
  hydrate,
  type DehydrateOptions,
  type Query,
  type QueryClient,
} from '@tanstack/react-query'
import type { PersistedClient } from '@tanstack/react-query-persist-client'
import { createSyncStoragePersister } from '@tanstack/query-sync-storage-persister'

const DEFAULT_KEY_PREFIX = 'pq'
const DEFAULT_MAX_AGE_MS = 24 * 60 * 60 * 1000
const DEFAULT_MAX_BYTES = 4 * 1024 * 1024
const DEFAULT_THROTTLE_MS = 1000
const ANON_USER_KEY = 'anon'

export interface SyncStorageLike {
  getItem(key: string): string | null
  setItem(key: string, value: string): void
  removeItem(key: string): void
  key?(index: number): string | null
  length?: number
}

export interface PersistOptions {
  userId: string | null
  buster: string
  storage?: SyncStorageLike | null | undefined
  keyPrefix?: string
  maxAgeMs?: number
  maxBytes?: number
  throttleMs?: number
  shouldDehydrateQuery?: (query: Query) => boolean
}

interface PersistedEnvelope {
  buster: string
  timestamp: number
  clientState: PersistedClient['clientState']
}

class QueryPersistenceError extends Error {
  constructor(message: string) {
    super(message)
    this.name = 'QueryPersistenceError'
  }
}

function isBrowserStorage(storage: SyncStorageLike | null | undefined): storage is SyncStorageLike {
  return storage != null
}

function defaultStorage(): SyncStorageLike | undefined {
  if (typeof window === 'undefined' || typeof window.localStorage === 'undefined') {
    return undefined
  }

  return window.localStorage
}

function normalizeUserId(userId: string | null): string {
  const trimmed = userId?.trim()
  return trimmed && trimmed.length > 0 ? trimmed : ANON_USER_KEY
}

function normalizeBuster(buster: string): string {
  const trimmed = buster.trim()
  if (trimmed.length === 0) {
    throw new QueryPersistenceError('PersistOptions.buster must be a non-empty string')
  }

  return trimmed
}

function normalizePrefix(keyPrefix: string | undefined): string {
  const trimmed = keyPrefix?.trim()
  if (!trimmed) {
    return DEFAULT_KEY_PREFIX
  }

  return trimmed
}

function defaultShouldDehydrateQuery(query: Query): boolean {
  if (query.state.status !== 'success') {
    return false
  }

  const meta = query.meta as { persist?: boolean } | undefined
  return meta?.persist !== false
}

function createEnvelope(
  queryClient: QueryClient,
  options: Pick<PersistOptions, 'buster' | 'shouldDehydrateQuery'>,
): PersistedEnvelope {
  const dehydrateOptions: DehydrateOptions = {
    shouldDehydrateQuery: options.shouldDehydrateQuery ?? defaultShouldDehydrateQuery,
  }

  return {
    buster: normalizeBuster(options.buster),
    timestamp: Date.now(),
    clientState: dehydrate(queryClient, dehydrateOptions),
  }
}

function isPersistedEnvelope(value: unknown): value is PersistedEnvelope {
  if (typeof value !== 'object' || value === null) {
    return false
  }

  const candidate = value as Partial<PersistedEnvelope>
  return (
    typeof candidate.buster === 'string' &&
    typeof candidate.timestamp === 'number' &&
    typeof candidate.clientState === 'object' &&
    candidate.clientState !== null
  )
}

function readEnvelope(
  storage: SyncStorageLike,
  key: string,
  buster: string,
  maxAgeMs: number,
): PersistedEnvelope | null {
  const raw = storage.getItem(key)
  if (raw == null) {
    return null
  }

  try {
    const parsed: unknown = JSON.parse(raw)
    if (!isPersistedEnvelope(parsed)) {
      storage.removeItem(key)
      return null
    }

    if (parsed.buster !== buster || Date.now() - parsed.timestamp > maxAgeMs) {
      storage.removeItem(key)
      return null
    }

    return parsed
  } catch {
    storage.removeItem(key)
    return null
  }
}

function encodedSize(value: string): number {
  return new TextEncoder().encode(value).length
}

function trimOldestQueries(envelope: PersistedEnvelope): PersistedEnvelope {
  return {
    ...envelope,
    clientState: {
      ...envelope.clientState,
      queries: [...envelope.clientState.queries].sort(
        (left, right) => left.state.dataUpdatedAt - right.state.dataUpdatedAt,
      ),
    },
  }
}

function enforceSizeLimit(
  queryClient: QueryClient,
  baseEnvelope: PersistedEnvelope,
  key: string,
  maxBytes: number,
  persister: ReturnType<typeof createSyncStoragePersister>,
): void {
  let envelope = trimOldestQueries(baseEnvelope)
  let serialized = JSON.stringify(envelope)

  while (encodedSize(serialized) > maxBytes && envelope.clientState.queries.length > 0) {
    envelope = {
      ...envelope,
      clientState: {
        ...envelope.clientState,
        queries: envelope.clientState.queries.slice(1),
      },
    }
    serialized = JSON.stringify(envelope)
  }

  if (encodedSize(serialized) > maxBytes) {
    return
  }

  hydrate(queryClient, envelope.clientState)
  void persister.persistClient({
    buster: envelope.buster,
    timestamp: envelope.timestamp,
    clientState: envelope.clientState,
  })
}

export function persistKeyForUser(userId: string | null): string {
  return `${DEFAULT_KEY_PREFIX}:${normalizeUserId(userId)}`
}

export function installPersistence(
  queryClient: QueryClient,
  options: PersistOptions,
): () => void {
  const storage = options.storage ?? defaultStorage()
  if (!isBrowserStorage(storage)) {
    return () => undefined
  }

  const buster = normalizeBuster(options.buster)
  const keyPrefix = normalizePrefix(options.keyPrefix)
  const storageKey = `${keyPrefix}:${normalizeUserId(options.userId)}`
  const maxAgeMs = options.maxAgeMs ?? DEFAULT_MAX_AGE_MS
  const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES
  const throttleMs = options.throttleMs ?? DEFAULT_THROTTLE_MS
  const persister = createSyncStoragePersister({
    storage,
    key: storageKey,
    throttleTime: 0,
  })

  const restored = readEnvelope(storage, storageKey, buster, maxAgeMs)
  if (restored) {
    hydrate(queryClient, restored.clientState)
  }

  let timeoutId: ReturnType<typeof setTimeout> | null = null
  let active = true

  const flush = (): void => {
    timeoutId = null
    if (!active) {
      return
    }

    try {
      const envelope = createEnvelope(queryClient, options)
      enforceSizeLimit(queryClient, envelope, storageKey, maxBytes, persister)
    } catch {
      storage.removeItem(storageKey)
    }
  }

  const unsubscribe = queryClient.getQueryCache().subscribe(() => {
    if (!active) {
      return
    }

    if (timeoutId !== null) {
      clearTimeout(timeoutId)
    }

    timeoutId = setTimeout(flush, throttleMs)
  })

  return () => {
    active = false
    if (timeoutId !== null) {
      clearTimeout(timeoutId)
      timeoutId = null
    }
    unsubscribe()
  }
}

export function clearPersistedCache(
  options: {
    keyPrefix?: string
    allUsers?: boolean
    storage?: SyncStorageLike | null | undefined
  } = {},
): void {
  const storage = options.storage ?? defaultStorage()
  if (!isBrowserStorage(storage)) {
    return
  }

  const keyPrefix = normalizePrefix(options.keyPrefix)
  const allUsers = options.allUsers ?? true

  if (!allUsers) {
    storage.removeItem(`${keyPrefix}:${ANON_USER_KEY}`)
    return
  }

  if (typeof storage.length !== 'number' || typeof storage.key !== 'function') {
    return
  }

  const keysToRemove: string[] = []
  for (let index = 0; index < storage.length; index += 1) {
    const key = storage.key(index)
    if (key?.startsWith(`${keyPrefix}:`)) {
      keysToRemove.push(key)
    }
  }

  for (const key of keysToRemove) {
    storage.removeItem(key)
  }
}
