import { defineStore } from 'pinia'
import { merge } from 'lodash'
import {
  computed,
  ComputedRef,
  MaybeRefOrGetter,
  readonly,
  ref,
  Ref,
  shallowReadonly,
  toRef,
  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<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
}

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<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,
  }
}

export function APIListUnpaginated<T extends APIObject>(api: ExtendableAPI<T>) {
  async function list(requestInit?: RequestInit): Promise<T[]> {
    const res = await fetch(api.createRequest(api.endpoint(), requestInit))
    await api.maybeRaiseResponse(res)
    const items: T[] = await res.json()
    for (const item of items) {
      api.itemMap.value.set(item.id, item)
    }
    return items
  }

  return { list }
}

export function APIListPaginated<T extends APIObject>(
  api: ExtendableAPI<T>,
  mode: 'offset' | 'page' = 'offset',
  limit = 20,
) {
  const count = ref<number>(0)
  const currentPage = ref<number>(1)
  const nextPage = ref<string | null>(null)
  const hasNext = computed(() => nextPage.value !== null)
  const previousPage = ref<string | null>(null)
  const hasPrevious = computed(() => previousPage.value !== null)
  async function list(page?: number, requestInit?: RequestInit): Promise<T[]> {
    page = page ?? currentPage.value
    const query = new URLSearchParams()
    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), requestInit))
    await api.maybeRaiseResponse(res)
    const data: PaginatedResults<T> = await res.json()
    currentPage.value = page
    count.value = data.count
    nextPage.value = data.next
    previousPage.value = data.previous

    for (const item of data.results) {
      api.itemMap.value.set(item.id, item)
    }
    return data.results
  }

  function reset() {
    api.itemMap.value.clear()
    currentPage.value = 1
    count.value = 0
    nextPage.value = null
    previousPage.value = null
  }

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

export function APIRetrieve<T extends APIObject>(api: ExtendableAPI<T>) {
  async function retrieve(
    id: ID,
    requestInit?: RequestInit,
    options?: { useCached?: boolean },
  ): 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()), 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
    }
  }

  return { retrieve }
}

export function APIUpdate<T extends APIObject, TData = Partial<T>>(api: ExtendableAPI<T>) {
  async function update(id: ID, data: TData | FormData, requestInit?: RequestInit): Promise<T> {
    const res = await fetch(
      api.createRequest(api.endpoint(id.toString()), requestInit, {
        method: 'PUT',
        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
  }

  return { update }
}

export function APICreate<T extends APIObject, TData = Partial<Omit<T, 'id'>>>(
  api: ExtendableAPI<T>,
) {
  async function create(data: TData | FormData, requestInit?: RequestInit): Promise<T> {
    const res = await fetch(
      api.createRequest(api.endpoint(), 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: ID, requestInit?: RequestInit): Promise<void> {
    const res = await fetch(
      api.createRequest(api.endpoint(id.toString()), 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<ID, T>
    retrieve: ReturnType<typeof APIRetrieve<T>>['retrieve']
  },
  id: MaybeRefOrGetter<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, undefined, { 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[]>[]>
}