import { HttpHeaders } from '@angular/common/http';

import { getAuthToken } from '../core/auth';
import type {
  QuerySerializer,
  QuerySerializerOptions,
} from '../core/bodySerializer';
import {
  serializeArrayParam,
  serializeObjectParam,
  serializePrimitiveParam,
} from '../core/pathSerializer';
import type { Client, ClientOptions, Config, RequestOptions } from './types';

interface PathSerializer {
  path: Record<string, unknown>;
  url: string;
}

const PATH_PARAM_RE = /\{[^{}]+\}/g;

type ArrayStyle = 'form' | 'spaceDelimited' | 'pipeDelimited';
type MatrixStyle = 'label' | 'matrix' | 'simple';
type ArraySeparatorStyle = ArrayStyle | MatrixStyle;

const defaultPathSerializer = ({ path, url: _url }: PathSerializer) => {
  let url = _url;
  const matches = _url.match(PATH_PARAM_RE);
  if (matches) {
    for (const match of matches) {
      let explode = false;
      let name = match.substring(1, match.length - 1);
      let style: ArraySeparatorStyle = 'simple';

      if (name.endsWith('*')) {
        explode = true;
        name = name.substring(0, name.length - 1);
      }

      if (name.startsWith('.')) {
        name = name.substring(1);
        style = 'label';
      } else if (name.startsWith(';')) {
        name = name.substring(1);
        style = 'matrix';
      }

      const value = path[name];

      if (value === undefined || value === null) {
        continue;
      }

      if (Array.isArray(value)) {
        url = url.replace(match, serializeArrayParam({ explode, name, style, value }));
        continue;
      }

      if (typeof value === 'object') {
        url = url.replace(
          match,
          serializeObjectParam({
            explode,
            name,
            style,
            value: value as Record<string, unknown>,
            valueOnly: true,
          }),
        );
        continue;
      }

      if (style === 'matrix') {
        url = url.replace(
          match,
          `;${serializePrimitiveParam({
            name,
            value: value as string,
          })}`,
        );
        continue;
      }

      const replaceValue = encodeURIComponent(
        style === 'label' ? `.${value as string}` : (value as string),
      );
      url = url.replace(match, replaceValue);
    }
  }
  return url;
};

export const createQuerySerializer = <T = unknown>({
  parameters = {},
  ...args
}: QuerySerializerOptions = {}) => {
  const querySerializer = (queryParams: T) => {
    const search: string[] = [];
    if (queryParams && typeof queryParams === 'object') {
      for (const name in queryParams) {
        const value = queryParams[name];

        if (value === undefined || value === null) {
          continue;
        }

        const options = parameters[name] || args;

        if (Array.isArray(value)) {
          const serializedArray = serializeArrayParam({
            allowReserved: options.allowReserved,
            explode: true,
            name,
            style: 'form',
            value,
            ...options.array,
          });
          if (serializedArray) search.push(serializedArray);
        } else if (typeof value === 'object') {
          const serializedObject = serializeObjectParam({
            allowReserved: options.allowReserved,
            explode: true,
            name,
            style: 'deepObject',
            value: value as Record<string, unknown>,
            ...options.object,
          });
          if (serializedObject) search.push(serializedObject);
        } else {
          const serializedPrimitive = serializePrimitiveParam({
            allowReserved: options.allowReserved,
            name,
            value: value as string,
          });
          if (serializedPrimitive) search.push(serializedPrimitive);
        }
      }
    }
    return search.join('&');
  };
  return querySerializer;
};

/**
 * Infers parseAs value from provided Content-Type header.
 */
export const getParseAs = (
  contentType: string | null,
): 'blob' | 'formData' | 'json' | 'stream' | 'text' | undefined => {
  if (!contentType) {
    // If no Content-Type header is provided, the best we can do is return the raw response body,
    // which is effectively the same as the 'stream' option.
    return 'stream';
  }

  const cleanContent = contentType.split(';')[0]?.trim();

  if (!cleanContent) {
    return;
  }

  if (cleanContent.startsWith('application/json') || cleanContent.endsWith('+json')) {
    return 'json';
  }

  if (cleanContent === 'multipart/form-data') {
    return 'formData';
  }

  if (
    ['application/', 'audio/', 'image/', 'video/'].some((type) => cleanContent.startsWith(type))
  ) {
    return 'blob';
  }

  if (cleanContent.startsWith('text/')) {
    return 'text';
  }

  return;
};

export async function setAuthParams(
  options: Pick<RequestOptions, 'auth' | 'query' | 'security'> & {
    headers: HttpHeaders;
  },
): Promise<void> {
  for (const auth of options.security ?? []) {
    const token = await getAuthToken(auth, options.auth);

    if (!token) {
      continue;
    }

    const name = auth.name ?? 'Authorization';

    switch (auth.in) {
      case 'query':
        if (!options.query) {
          options.query = {};
        }
        options.query[name] = token;
        break;
      case 'cookie':
        options.headers = options.headers.append('Cookie', `${name}=${token}`);
        break;
      case 'header':
      default:
        options.headers = options.headers.set(name, token);
        break;
    }

    return;
  }
}

export const buildUrl: Client['buildUrl'] = (options) => {
  const url = getUrl({
    baseUrl: options.baseUrl as string,
    path: options.path,
    query: options.query,
    querySerializer:
      typeof options.querySerializer === 'function'
        ? options.querySerializer
        : createQuerySerializer(options.querySerializer),
    url: options.url,
  });
  return url;
};

export const getUrl = ({
  baseUrl,
  path,
  query,
  querySerializer,
  url: _url,
}: {
  baseUrl?: string;
  path?: Record<string, unknown>;
  query?: Record<string, unknown>;
  querySerializer: QuerySerializer;
  url: string;
}) => {
  const pathUrl = _url.startsWith('/') ? _url : `/${_url}`;
  let url = (baseUrl ?? '') + pathUrl;
  if (path) {
    url = defaultPathSerializer({ path, url });
  }
  let search = query ? querySerializer(query) : '';
  if (search.startsWith('?')) {
    search = search.substring(1);
  }
  if (search) {
    url += `?${search}`;
  }
  return url;
};

export const mergeConfigs = (a: Config, b: Config): Config => {
  const config = { ...a, ...b };
  if (config.baseUrl?.endsWith('/')) {
    config.baseUrl = config.baseUrl.substring(0, config.baseUrl.length - 1);
  }
  config.headers = mergeHeaders(a.headers, b.headers);
  return config;
};

export const mergeHeaders = (
  ...headers: Array<Required<Config>['headers'] | undefined>
): HttpHeaders => {
  let mergedHeaders = new HttpHeaders();

  for (const header of headers) {
    if (!header || typeof header !== 'object') {
      continue;
    }

    if (header instanceof HttpHeaders) {
      // Merge HttpHeaders instance
      header.keys().forEach((key) => {
        const values = header.getAll(key);
        if (values) {
          values.forEach((value) => {
            mergedHeaders = mergedHeaders.append(key, value);
          });
        }
      });
    } else {
      // Merge plain object headers
      for (const [key, value] of Object.entries(header)) {
        if (value === null) {
          mergedHeaders = mergedHeaders.delete(key);
        } else if (Array.isArray(value)) {
          for (const v of value) {
            mergedHeaders = mergedHeaders.append(key, v as string);
          }
        } else if (value !== undefined) {
          // assume object headers are meant to be JSON stringified, i.e., their
          // content value in OpenAPI specification is 'application/json'
          mergedHeaders = mergedHeaders.set(
            key,
            typeof value === 'object' ? JSON.stringify(value) : (value as string),
          );
        }
      }
    }
  }

  return mergedHeaders;
};

type ErrInterceptor<Err, Res, Req, Options> = (
  error: Err,
  /** response may be undefined due to a network error where no response object is produced */
  response: Res | undefined,
  /** request may be undefined, because error may be from building the request object itself */
  request: Req | undefined,
  options: Options,
) => Err | Promise<Err>;

type ReqInterceptor<Req, Options> = (request: Req, options: Options) => Req | Promise<Req>;

type ResInterceptor<Res, Req, Options> = (
  response: Res,
  request: Req,
  options: Options,
) => Res | Promise<Res>;

class Interceptors<Interceptor> {
  fns: Array<Interceptor | null> = [];

  clear(): void {
    this.fns = [];
  }

  eject(id: number | Interceptor): void {
    const index = this.getInterceptorIndex(id);
    if (this.fns[index]) {
      this.fns[index] = null;
    }
  }

  exists(id: number | Interceptor): boolean {
    const index = this.getInterceptorIndex(id);
    return Boolean(this.fns[index]);
  }

  getInterceptorIndex(id: number | Interceptor): number {
    if (typeof id === 'number') {
      return this.fns[id] ? id : -1;
    }
    return this.fns.indexOf(id);
  }

  update(id: number | Interceptor, fn: Interceptor): number | Interceptor | false {
    const index = this.getInterceptorIndex(id);
    if (this.fns[index]) {
      this.fns[index] = fn;
      return id;
    }
    return false;
  }

  use(fn: Interceptor): number {
    this.fns.push(fn);
    return this.fns.length - 1;
  }
}

export interface Middleware<Req, Res, Err, Options> {
  error: Interceptors<ErrInterceptor<Err, Res, Req, Options>>;
  request: Interceptors<ReqInterceptor<Req, Options>>;
  response: Interceptors<ResInterceptor<Res, Req, Options>>;
}

export const createInterceptors = <Req, Res, Err, Options>(): Middleware<
  Req,
  Res,
  Err,
  Options
> => ({
  error: new Interceptors<ErrInterceptor<Err, Res, Req, Options>>(),
  request: new Interceptors<ReqInterceptor<Req, Options>>(),
  response: new Interceptors<ResInterceptor<Res, Req, Options>>(),
});

const defaultQuerySerializer = createQuerySerializer({
  allowReserved: false,
  array: {
    explode: true,
    style: 'form',
  },
  object: {
    explode: true,
    style: 'deepObject',
  },
});

const defaultHeaders = {
  'Content-Type': 'application/json',
};

export const createConfig = <T extends ClientOptions = ClientOptions>(
  override: Config<Omit<ClientOptions, keyof T> & T> = {},
): Config<Omit<ClientOptions, keyof T> & T> => ({
  headers: defaultHeaders,
  querySerializer: defaultQuerySerializer,
  ...override,
});
