import { useAsyncData, useFetch, useLazyAsyncData, useLazyFetch } from 'nuxt/app';
import { reactive, ref, toValue, watch } from 'vue';

import { createSseClient } from '../core/serverSentEvents';
import type { HttpMethod } from '../core/types';
import { getValidRequestBody } from '../core/utils';
import type { Client, Config, RequestOptions } from './types';
import {
  buildUrl,
  createConfig,
  executeFetchFn,
  mergeConfigs,
  mergeHeaders,
  mergeInterceptors,
  serializeBody,
  setAuthParams,
  unwrapRefs,
} from './utils';

export const createClient = (config: Config = {}): Client => {
  let _config = mergeConfigs(createConfig(), config);

  const getConfig = (): Config => ({ ..._config });

  const setConfig = (config: Config): Config => {
    _config = mergeConfigs(_config, config);
    return getConfig();
  };

  const beforeRequest = async (options: RequestOptions) => {
    const opts = {
      ..._config,
      ...options,
      $fetch: options.$fetch ?? _config.$fetch ?? $fetch,
      headers: mergeHeaders(_config.headers, options.headers),
      onRequest: mergeInterceptors(_config.onRequest, options.onRequest),
      onResponse: mergeInterceptors(_config.onResponse, options.onResponse),
    };

    if (opts.security) {
      await setAuthParams(opts);
    }

    if (opts.requestValidator) {
      await opts.requestValidator(opts);
    }

    const url = buildUrl(opts);

    return { opts, url };
  };

  const request: Client['request'] = ({ asyncDataOptions, composable = '$fetch', ...options }) => {
    const key = options.key;
    const opts = {
      ..._config,
      ...options,
      $fetch: options.$fetch ?? _config.$fetch ?? $fetch,
      headers: mergeHeaders(_config.headers, options.headers),
      onRequest: mergeInterceptors(_config.onRequest, options.onRequest),
      onResponse: mergeInterceptors(_config.onResponse, options.onResponse),
    };

    const { requestValidator, responseTransformer, responseValidator, security } = opts;
    if (requestValidator || security) {
      // auth must happen in interceptors otherwise we'd need to require
      // asyncContext enabled
      // https://nuxt.com/docs/guide/going-further/experimental-features#asynccontext
      opts.onRequest = [
        async ({ options }) => {
          if (security) {
            const authOpts: Parameters<typeof setAuthParams>[0] = {
              auth: opts.auth,
              headers: options.headers,
              query: options.query,
              security,
            };
            await setAuthParams(authOpts);
            if (authOpts.query !== options.query) {
              options.query = authOpts.query;
            }
          }

          if (requestValidator) {
            await requestValidator({
              ...options,
              // @ts-expect-error
              body: options.rawBody,
            });
          }
        },
        ...opts.onRequest,
      ];
    }

    if (responseTransformer || responseValidator) {
      opts.onResponse = [
        ...opts.onResponse,
        async ({ options, response }) => {
          if (options.responseType && options.responseType !== 'json') {
            return;
          }

          if (!response.ok) {
            return;
          }

          if (responseValidator) {
            await responseValidator(response._data);
          }

          if (responseTransformer) {
            response._data = await responseTransformer(response._data);
          }
        },
      ];
    }

    // remove Content-Type header if body is empty to avoid sending invalid requests
    if (opts.body === undefined || opts.body === '') {
      opts.headers.delete('Content-Type');
    }

    const fetchFn = opts.$fetch;

    if (composable === '$fetch') {
      return executeFetchFn(
        // @ts-expect-error
        opts,
        fetchFn,
      );
    }

    if (composable === 'useFetch' || composable === 'useLazyFetch') {
      opts.rawBody = opts.body;
      const bodyParams = reactive({
        body: opts.body,
        bodySerializer: opts.bodySerializer,
      });
      const body = ref(serializeBody({ ...opts, body: toValue(opts.body) }));
      opts.body = body;
      watch(bodyParams, (changed) => {
        body.value = serializeBody(changed);
      });
      return composable === 'useLazyFetch'
        ? useLazyFetch(() => buildUrl(opts), { ...opts, ...asyncDataOptions })
        : useFetch(() => buildUrl(opts), { ...opts, ...asyncDataOptions });
    }

    const handler: any = () =>
      executeFetchFn(
        // @ts-expect-error
        opts,
        fetchFn,
      );

    if (composable === 'useAsyncData') {
      return key
        ? useAsyncData(key, handler, asyncDataOptions)
        : useAsyncData(handler, asyncDataOptions);
    }

    if (composable === 'useLazyAsyncData') {
      return key
        ? useLazyAsyncData(key, handler, asyncDataOptions)
        : useLazyAsyncData(handler, asyncDataOptions);
    }

    return undefined as any;
  };

  const makeMethodFn = (method: Uppercase<HttpMethod>) => (options: RequestOptions) =>
    request({ ...options, method });

  const makeSseFn = (method: Uppercase<HttpMethod>) => async (options: RequestOptions) => {
    const { opts, url } = await beforeRequest(options);
    return createSseClient({
      ...unwrapRefs(opts),
      body: opts.body as BodyInit | null | undefined,
      method,
      onRequest: undefined,
      serializedBody: getValidRequestBody(opts) as BodyInit | null | undefined,
      signal: unwrapRefs(opts.signal) as AbortSignal,
      url,
    });
  };

  const _buildUrl: Client['buildUrl'] = (options) =>
    buildUrl({ ..._config, ...options } as typeof options);

  return {
    buildUrl: _buildUrl,
    connect: makeMethodFn('CONNECT'),
    delete: makeMethodFn('DELETE'),
    get: makeMethodFn('GET'),
    getConfig,
    head: makeMethodFn('HEAD'),
    options: makeMethodFn('OPTIONS'),
    patch: makeMethodFn('PATCH'),
    post: makeMethodFn('POST'),
    put: makeMethodFn('PUT'),
    request,
    setConfig,
    sse: {
      connect: makeSseFn('CONNECT'),
      delete: makeSseFn('DELETE'),
      get: makeSseFn('GET'),
      head: makeSseFn('HEAD'),
      options: makeSseFn('OPTIONS'),
      patch: makeSseFn('PATCH'),
      post: makeSseFn('POST'),
      put: makeSseFn('PUT'),
      trace: makeSseFn('TRACE'),
    },
    trace: makeMethodFn('TRACE'),
  } as Client;
};
