import { ofetch, type ResponseType as OfetchResponseType } from 'ofetch';

import { createSseClient } from '../core/serverSentEvents';
import type { HttpMethod } from '../core/types';
import { getValidRequestBody } from '../core/utils';
import type { Client, Config, RequestOptions, ResolvedRequestOptions } from './types';
import {
  buildOfetchOptions,
  buildUrl,
  createConfig,
  createInterceptors,
  isRepeatableBody,
  mapParseAsToResponseType,
  mergeConfigs,
  mergeHeaders,
  parseError,
  parseSuccess,
  setAuthParams,
  wrapDataReturn,
  wrapErrorReturn,
} from './utils';

type ReqInit = Omit<RequestInit, 'body' | 'headers'> & {
  body?: BodyInit | null | undefined;
  headers: ReturnType<typeof mergeHeaders>;
};

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 interceptors = createInterceptors<Request, Response, unknown, ResolvedRequestOptions>();

  // precompute serialized / network body
  const resolveOptions = async (options: RequestOptions) => {
    const opts = {
      ..._config,
      ...options,
      headers: mergeHeaders(_config.headers, options.headers),
      serializedBody: undefined as string | undefined,
    };

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

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

    if (opts.body !== undefined && opts.bodySerializer) {
      opts.serializedBody = opts.bodySerializer(opts.body) as string | undefined;
    }

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

    // if a raw body is provided (no serializer), adjust Content-Type only when it
    // equals the default JSON value to better match the concrete body type
    if (
      opts.body !== undefined &&
      opts.bodySerializer === null &&
      (opts.headers.get('Content-Type') || '').toLowerCase() === 'application/json'
    ) {
      const b: unknown = opts.body;
      if (typeof FormData !== 'undefined' && b instanceof FormData) {
        // let the runtime set the multipart boundary
        opts.headers.delete('Content-Type');
      } else if (typeof URLSearchParams !== 'undefined' && b instanceof URLSearchParams) {
        // standard urlencoded content type (+ charset)
        opts.headers.set('Content-Type', 'application/x-www-form-urlencoded;charset=UTF-8');
      } else if (typeof Blob !== 'undefined' && b instanceof Blob) {
        const t = b.type?.trim();
        if (t) {
          opts.headers.set('Content-Type', t);
        } else {
          // unknown blob type: avoid sending a misleading JSON header
          opts.headers.delete('Content-Type');
        }
      }
    }

    // precompute network body (stability for retries and interceptors)
    const networkBody = getValidRequestBody(opts) as RequestInit['body'] | null | undefined;

    const url = buildUrl(opts);

    return { networkBody, opts, url };
  };

  // apply request interceptors and mirror header/method/signal back to opts
  const applyRequestInterceptors = async (
    request: Request,
    opts: ResolvedRequestOptions,
    body: BodyInit | null | undefined,
  ) => {
    for (const fn of interceptors.request.fns) {
      if (fn) {
        request = await fn(request, opts);
      }
    }
    // reflect interceptor changes into opts used by the network layer
    opts.headers = request.headers;
    opts.method = request.method as Uppercase<HttpMethod>;
    // ignore request.body changes to avoid turning serialized bodies into streams
    // body comes only from getValidRequestBody(options)
    // reflect signal if present
    opts.signal = request.signal;

    // When body is FormData, remove Content-Type header to avoid boundary mismatch.
    // Note: We already delete Content-Type in resolveOptions for FormData, but the
    // Request constructor (line 175) re-adds it with an auto-generated boundary.
    // Since we pass the original FormData (not the Request's body) to ofetch, and
    // ofetch will generate its own boundary, we must remove the Request's Content-Type
    // to let ofetch set the correct one. Otherwise the boundary in the header won't
    // match the boundary in the actual multipart body sent by ofetch.
    if (typeof FormData !== 'undefined' && body instanceof FormData) {
      opts.headers.delete('Content-Type');
    }

    return request;
  };

  // build ofetch options with stable retry logic based on body repeatability
  const buildNetworkOptions = (
    opts: ResolvedRequestOptions,
    body: BodyInit | null | undefined,
    responseType: OfetchResponseType | undefined,
  ) => {
    const effectiveRetry = isRepeatableBody(body) ? opts.retry : 0;
    return buildOfetchOptions(opts, body, responseType, effectiveRetry);
  };

  const request: Client['request'] = async (options) => {
    const throwOnError = options.throwOnError ?? _config.throwOnError;
    const responseStyle = options.responseStyle ?? _config.responseStyle;

    let request: Request | undefined;
    let response: Awaited<ReturnType<typeof ofetch.raw>> | undefined;

    try {
      const {
        networkBody: initialNetworkBody,
        opts,
        url,
      } = await resolveOptions(options as RequestOptions);
      // map parseAs -> ofetch responseType once per request
      const ofetchResponseType: OfetchResponseType | undefined = mapParseAsToResponseType(
        opts.parseAs,
        opts.responseType,
      );

      const $ofetch = opts.ofetch ?? ofetch;

      // create Request before network to run middleware consistently
      const networkBody = initialNetworkBody;
      const requestInit: ReqInit = {
        body: networkBody,
        headers: opts.headers,
        method: opts.method,
        redirect: 'follow',
        signal: opts.signal,
      };
      request = new Request(url, requestInit);

      request = await applyRequestInterceptors(request, opts, networkBody);
      const finalUrl = request.url;

      // build ofetch options and perform the request (.raw keeps the Response)
      const responseOptions = buildNetworkOptions(opts, networkBody, ofetchResponseType);

      response = await $ofetch.raw(finalUrl, responseOptions);

      for (const fn of interceptors.response.fns) {
        if (fn) {
          response = await fn(response, request, opts);
        }
      }

      const result = { request, response };

      if (response.ok) {
        const data = await parseSuccess(response, opts, ofetchResponseType);
        return wrapDataReturn(data, result, opts.responseStyle);
      }

      throw await parseError(response);
    } catch (error) {
      let finalError = error;

      for (const fn of interceptors.error.fns) {
        if (fn) {
          finalError = await fn(finalError, response, request, options as ResolvedRequestOptions);
        }
      }

      // ensure error is never undefined after interceptors
      finalError = finalError || ({} as string);

      if (throwOnError) {
        throw finalError;
      }

      return wrapErrorReturn(finalError, { request, response }, responseStyle) as any;
    }
  };

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

  const makeSseFn = (method: Uppercase<HttpMethod>) => async (options: RequestOptions) => {
    const { networkBody, opts, url } = await resolveOptions(options);
    const optsForSse = { ...opts };
    delete optsForSse.body; // body is provided via serializedBody below
    return createSseClient({
      ...(optsForSse as Omit<typeof opts, 'body'>),
      fetch: opts.fetch,
      headers: opts.headers,
      method,
      onRequest: async (url, init) => {
        let request = new Request(url, init);
        request = await applyRequestInterceptors(request, opts, networkBody);
        return request;
      },
      serializedBody: networkBody,
      signal: opts.signal,
      url,
    });
  };

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

  return {
    buildUrl: _buildUrl,
    connect: makeMethodFn('CONNECT'),
    delete: makeMethodFn('DELETE'),
    get: makeMethodFn('GET'),
    getConfig,
    head: makeMethodFn('HEAD'),
    interceptors,
    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;
};
