import { computedAsync } from '@vueuse/core'
import { merge } from 'lodash'
import { defineStore } from 'pinia'
import {
  computed,
  ComputedRef,
  isReadonly,
  isRef,
  MaybeRefOrGetter,
  readonly,
  ref,
  Ref,
  shallowReadonly,
  toRef,
  watch,
  watchEffect,
} from 'vue'

export const SERVER_ERRORS_GLOBAL: unique symbol = Symbol('ERRORS_GLOBAL')

export type ID = number | string

type ErrorDetail = {
  message: string
  code: string
}

type ErrorMap = Record<string, ErrorDetail[]> & {
  [SERVER_ERRORS_GLOBAL]: ErrorDetail[]
}

export class APIError extends Error {
  constructor(message: string) {
    super(message)
  }
}

export class APIResponseError extends APIError {
  response: Response
  data: unknown

  constructor(message: string, response: Response, data: unknown) {
    super(message)
    this.response = response
    this.data = data
  }
}

type APIObject = { id: ID }

type ExtendableAPI<T extends APIObject> = {
  itemMap: Ref<Map<T['id'], T>>
  endpoint: URLBuilder
  maybeRaiseResponse: (res: Response) => Promise<void>
  createRequest: (
    url: string,
    customRequestData: RequestInit | undefined,
    defaultRequestData?: RequestInit | undefined,
  ) => Request
}

type APIStoreOptions<T extends APIObject> = {
  getRequestDefaults?: () => RequestInit | undefined
}

type BaseRequestOptions = {
  requestInit?: RequestInit | undefined
}

type PaginatedResults<T> = {
  count: number
  next: string | null
  previous: string | null
  results: T[]
}

type URLToken = string | number
type URLBuilder = (...subPaths: URLToken[] | [...URLToken[], URLToken | URLSearchParams]) => string
type PrefixableURLBuilder = URLBuilder & {
  prefix: (...prefixes: URLToken[]) => PrefixableURLBuilder
}

function createURLBuilder(basepath: string, useTrailingSlash = true): PrefixableURLBuilder {
  // Strip all trailing slashes from the basepath.
  // We handle slashes when building URLs.
  basepath = basepath.replace(/\/*$/, '')

  const buildURL: PrefixableURLBuilder = (...subPaths) => {
    let params
    if (subPaths.at(-1) instanceof URLSearchParams) {
      params = subPaths.pop()
    }
    if (subPaths.some((path) => String(path).includes('/'))) {
      throw new Error('Subpaths must not contain slashes')
    }
    const subPath = subPaths.length > 0 ? '/' + subPaths.join('/') : ''
    const url = basepath + subPath + (useTrailingSlash ? '/' : '')
    return params ? url + `?${params}` : url
  }

  buildURL.prefix = function (...prefixes: URLToken[]) {
    return createURLBuilder(buildURL(...prefixes), useTrailingSlash)
  }

  return buildURL
}

export const createTankURL = createURLBuilder(import.meta.env.VUE_APP_API_TANK, false)
export const createSteeringURL = createURLBuilder(import.meta.env.VUE_APP_API_STEERING)

export function createExtendableAPI<T extends APIObject>(
  endpoint: URLBuilder,
  options?: APIStoreOptions<T>,
) {
  const itemMap = ref<Map<T['id'], T>>(new Map())
  const items = computed<T[]>(() => Array.from(itemMap.value.values()))
  const error = ref<Error>()

  async function maybeRaiseResponse(response: Response) {
    if (!response.ok) {
      let data = null
      try {
        data = await response.json()
      } catch (e) {
        // pass
      }
      const _error = new APIResponseError(
        `Failure response when executing when interacting with ${response.url}`,
        response,
        data,
      )
      error.value = _error
      throw _error
    }
  }

  function createRequest(
    url: string,
    customRequestData: RequestInit | undefined,
    defaultRequestData?: RequestInit | undefined,
  ) {
    return new Request(
      url,
      merge(
        options?.getRequestDefaults?.() ?? {},
        defaultRequestData ?? {},
        customRequestData ?? {},
      ),
    )
  }

  function reset() {
    itemMap.value.clear()
  }

  return {
    base: {
      error: readonly(error),
      items,
      itemMap: shallowReadonly(itemMap),
      reset,
    },
    createRequest,
    endpoint,
    maybeRaiseResponse,
    itemMap,
  }
}

type ListOperationOptions = BaseRequestOptions & {
  limit?: number | undefined
  noStoreCommit?: boolean | undefined
  query?: URLSearchParams | undefined
}

export function APIListUnpaginated<T extends APIObject>(api: ExtendableAPI<T>) {
  async function list(options: Omit<ListOperationOptions, 'limit'> = {}): Promise<T[]> {
    const query = options.query ?? new URLSearchParams()
    const init = options.requestInit
    const res = await fetch(api.createRequest(api.endpoint(query), init))
    await api.maybeRaiseResponse(res)
    const items: T[] = await res.json()
    if (options.noStoreCommit !== true) {
      for (const item of items) {
        api.itemMap.value.set(item.id, item)
      }
    }
    return items
  }

  return { list }
}

export type PaginatedListResult<T> = {
  items: T[]
  page: number
  count: number
  numberOfPages: number
  itemsPerPage: number
  itemRange: [number, number]
  hasNext: boolean
  hasPrevious: boolean
}

export function APIListPaginated<T extends APIObject>(
  api: ExtendableAPI<T>,
  mode: 'offset' | 'page' = 'offset',
  limit = 20,
) {
  const count = ref(0)
  const currentPage = ref(1)
  const numberOfPages = ref(0)
  const hasNext = ref(false)
  const hasPrevious = ref(false)

  async function listIsolated(
    page: number,
    options: ListOperationOptions = {},
  ): Promise<PaginatedListResult<T>> {
    const _page = page ?? currentPage.value
    const _limit = options.limit ?? limit
    const query = options.query ?? new URLSearchParams()
    const init = options.requestInit
    if (mode === 'offset') {
      query.set('limit', _limit.toString())
      query.set('offset', ((_page - 1) * _limit).toString())
    } else {
      query.set('pageSize', _limit.toString())
      query.set('page', _page.toString())
    }
    const res = await fetch(api.createRequest(api.endpoint(query), init))
    await api.maybeRaiseResponse(res)
    const data: PaginatedResults<T> = await res.json()

    const { results, count, next, previous } = data
    if (options?.noStoreCommit !== true) {
      for (const item of results) {
        api.itemMap.value.set(item.id, item)
      }
    }

    return {
      items: results,
      page: _page,
      count,
      itemsPerPage: _limit,
      numberOfPages: Math.ceil(count / _limit),
      hasNext: next !== null,
      hasPrevious: previous !== null,
      itemRange: [(_page - 1) * _limit + 1, Math.min(count, _page * _limit)],
    }
  }

  async function list(page?: number, options: ListOperationOptions = {}): Promise<T[]> {
    const _page = page ?? currentPage.value
    const data = await listIsolated(_page, options)
    currentPage.value = _page
    count.value = data.count
    numberOfPages.value = data.numberOfPages
    hasNext.value = data.hasNext
    hasPrevious.value = data.hasPrevious
    return data.items
  }

  function reset() {
    api.itemMap.value.clear()
    currentPage.value = 1
    count.value = 0
    hasNext.value = false
    hasPrevious.value = false
  }

  return {
    list,
    listIsolated,
    reset,
    hasNext,
    hasPrevious,
    count: readonly(count),
    currentPage: readonly(currentPage),
  }
}

export function APIRetrieve<T extends APIObject>(api: ExtendableAPI<T>) {
  type RetrieveOptions = { requestInit?: RequestInit | undefined; useCached?: boolean | undefined }

  async function retrieve(id: T['id'], options: RetrieveOptions = {}): Promise<T | null> {
    if (options?.useCached && api.itemMap.value.has(id)) {
      return api.itemMap.value.get(id) as T
    }

    const res = await fetch(api.createRequest(api.endpoint(id.toString()), options.requestInit))
    if (res.status === 404) {
      api.itemMap.value.delete(id)
      return null
    } else {
      await api.maybeRaiseResponse(res)
      const obj: T = await res.json()
      api.itemMap.value.set(obj.id, obj)
      return obj
    }
  }

  async function retrieveMultiple(
    ids: T['id'][],
    options: RetrieveOptions = {},
  ): Promise<PromiseSettledResult<T | null>[]> {
    return await Promise.allSettled(ids.map((id) => retrieve(id, options)))
  }

  return { retrieve, retrieveMultiple }
}

export function APIUpdate<
  T extends APIObject,
  TUpdateData = Partial<T>,
  TPartialUpdateData = Partial<TUpdateData>,
>(api: ExtendableAPI<T>) {
  function updateWith<TData>(method: 'PUT' | 'PATCH') {
    return async function (id: T['id'], data: FormData | TData, options: BaseRequestOptions = {}) {
      const res = await fetch(
        api.createRequest(api.endpoint(id.toString()), options.requestInit, {
          method,
          headers: data instanceof FormData ? undefined : { 'Content-Type': 'application/json' },
          body: data instanceof FormData ? data : JSON.stringify(data),
        }),
      )
      await api.maybeRaiseResponse(res)
      const obj: T = await res.json()
      api.itemMap.value.set(obj.id, obj)
      return obj
    }
  }

  const update = updateWith<TUpdateData>('PUT')
  const partialUpdate = updateWith<TPartialUpdateData>('PATCH')

  return { update, partialUpdate }
}

export function APICreate<T extends APIObject, TData = Partial<Omit<T, 'id'>>>(
  api: ExtendableAPI<T>,
) {
  async function create(data: TData | FormData, options: BaseRequestOptions = {}): Promise<T> {
    const res = await fetch(
      api.createRequest(api.endpoint(), options.requestInit, {
        method: 'POST',
        headers: data instanceof FormData ? undefined : { 'Content-Type': 'application/json' },
        body: data instanceof FormData ? data : JSON.stringify(data),
      }),
    )
    await api.maybeRaiseResponse(res)
    const obj = await res.json()
    api.itemMap.value.set(obj.id, obj)
    return obj
  }

  return { create }
}

export function APIRemove<T extends APIObject>(api: ExtendableAPI<T>) {
  async function remove(id: T['id'], options: BaseRequestOptions = {}): Promise<void> {
    const res = await fetch(
      api.createRequest(api.endpoint(id.toString()), options.requestInit, { method: 'DELETE' }),
    )
    await api.maybeRaiseResponse(res)
    api.itemMap.value.delete(id)
  }

  return { remove }
}

export function createUnpaginatedAPIStore<T extends APIObject>(
  storeId: string,
  endpoint: URLBuilder,
  options?: APIStoreOptions<T>,
) {
  return defineStore(storeId, () => {
    const { base, ...api } = createExtendableAPI<T>(endpoint, options)
    return {
      ...base,
      ...APIListUnpaginated(api),
      ...APIRetrieve(api),
      ...APICreate(api),
      ...APIUpdate(api),
      ...APIRemove(api),
    }
  })
}

export function useAPIObject<T extends APIObject>(
  store: {
    itemMap: Map<T['id'], T>
    retrieve: ReturnType<typeof APIRetrieve<T>>['retrieve']
  },
  id: MaybeRefOrGetter<T['id'] | null>,
) {
  const _id = toRef(id)
  const obj = computed<T | null>(() => {
    return _id.value !== null ? store.itemMap.get(_id.value) ?? null : null
  })
  const isLoading = ref(false)
  watchEffect(async () => {
    if (_id.value) {
      isLoading.value = true
      try {
        // Force an API request in case the object is not yet in the store.
        await store.retrieve(_id.value, { useCached: true })
      } finally {
        isLoading.value = false
      }
    }
  })
  return { obj, isLoading }
}

export function useServerErrors(error: Ref<Error | undefined>) {
  return computed<ErrorMap>(() => {
    const _error: Error | undefined = error.value
    const result: ErrorMap = { [SERVER_ERRORS_GLOBAL]: [] }
    if (_error instanceof APIResponseError) {
      const { status } = _error.response
      if (status >= 500) {
        result[SERVER_ERRORS_GLOBAL].push({ message: '', code: 'server.unknown' })
      }
      if (status >= 400) {
        if (_error.data !== null && typeof _error.data === 'object') {
          for (const [field, _errors] of Object.entries(_error.data)) {
            const errors = Array.isArray(_errors) ? _errors : [_errors]
            result[field] = errors.map((err: string | ErrorDetail) => {
              if (typeof err === 'string') {
                return { message: err, code: '' }
              } else {
                return { message: err.message, code: `server.${err.code}` }
              }
            })
          }
        }
      }
    }
    return result
  })
}

type FieldName = string | typeof SERVER_ERRORS_GLOBAL
type MappedFields<T> = { [K in keyof T]: T[K] }

export function useServerFieldErrors(
  error: Ref<Error | undefined>,
  ...fields: (FieldName | FieldName[])[]
) {
  const serverErrors = useServerErrors(error)
  const consumedFields = new Set(fields.flat())
  const result: ComputedRef<ErrorDetail[]>[] = []

  for (const field of fields) {
    const fieldNames: FieldName[] = Array.isArray(field) ? field : [field]
    const fieldErrors = computed(() => {
      const errors: ErrorDetail[] = []
      for (const fieldName of fieldNames) {
        errors.push(...(serverErrors.value[fieldName] ?? []))
      }
      return errors
    })
    result.push(fieldErrors)
  }

  // contains all errors for which no individual error container was created
  const remainingErrors = computed(() => {
    const errors: ErrorDetail[] = []
    for (const fieldName of Object.keys(serverErrors.value)) {
      if (!consumedFields.has(fieldName)) {
        errors.push(...serverErrors.value[fieldName])
      }
    }
    return errors
  })
  result.push(remainingErrors)

  return result as MappedFields<ComputedRef<ErrorDetail[]>[]>
}

export function usePaginatedList<T>(
  list: (page: number, options: ListOperationOptions) => Promise<PaginatedListResult<T>>,
  page: MaybeRefOrGetter<number>,
  options: {
    limit?: MaybeRefOrGetter<number | undefined>
    query?: MaybeRefOrGetter<URLSearchParams | undefined>
    noPageAutoReset?: boolean
  } = {},
) {
  const _page = toRef(page)
  const _limit = toRef(options.limit)
  const _query = toRef(options.query ?? new URLSearchParams())
  const isLoading = ref(false)

  const canResetPage = isRef(page) && !isReadonly(page)
  if (options.noPageAutoReset !== true && canResetPage) {
    watch([_query, _limit], () => {
      ;(_page as Ref<number>).value = 1
    })
  }

  const result = computedAsync(
    (onCancel) => {
      const controller = new AbortController()
      onCancel(() => controller.abort())
      return list(_page.value, {
        limit: _limit.value,
        requestInit: { signal: controller.signal },
        query: _query.value,
      })
    },
    {
      items: [],
      page: 1,
      count: 0,
      itemsPerPage: _limit.value ?? 20,
      numberOfPages: 1,
      hasNext: false,
      hasPrevious: false,
      itemRange: [0, 0],
    },
    {
      evaluating: isLoading,
      shallow: true,
    },
  )
  return { result, isLoading }
}