type Slot = 'body' | 'headers' | 'path' | 'query';

export type Field =
  | {
      in: Exclude<Slot, 'body'>;
      /**
       * Field name. This is the name we want the user to see and use.
       */
      key: string;
      /**
       * Field mapped name. This is the name we want to use in the request.
       * If omitted, we use the same value as `key`.
       */
      map?: string;
    }
  | {
      in: Extract<Slot, 'body'>;
      /**
       * Key isn't required for bodies.
       */
      key?: string;
      map?: string;
    }
  | {
      /**
       * Field name. This is the name we want the user to see and use.
       */
      key: string;
      /**
       * Field mapped name. This is the name we want to use in the request.
       * If `in` is omitted, `map` aliases `key` to the transport layer.
       */
      map: Slot;
    };

export interface Fields {
  allowExtra?: Partial<Record<Slot, boolean>>;
  args?: ReadonlyArray<Field>;
}

export type FieldsConfig = ReadonlyArray<Field | Fields>;

const extraPrefixesMap: Record<string, Slot> = {
  $body_: 'body',
  $headers_: 'headers',
  $path_: 'path',
  $query_: 'query',
};
const extraPrefixes = Object.entries(extraPrefixesMap);

type KeyMap = Map<
  string,
  | {
      in: Slot;
      map?: string;
    }
  | {
      in?: never;
      map: Slot;
    }
>;

const buildKeyMap = (fields: FieldsConfig, map?: KeyMap): KeyMap => {
  if (!map) {
    map = new Map();
  }

  for (const config of fields) {
    if ('in' in config) {
      if (config.key) {
        map.set(config.key, {
          in: config.in,
          map: config.map,
        });
      }
    } else if ('key' in config) {
      map.set(config.key, {
        map: config.map,
      });
    } else if (config.args) {
      buildKeyMap(config.args, map);
    }
  }

  return map;
};

interface Params {
  body: unknown;
  headers: Record<string, unknown>;
  path: Record<string, unknown>;
  query: Record<string, unknown>;
}

const stripEmptySlots = (params: Params) => {
  for (const [slot, value] of Object.entries(params)) {
    if (value && typeof value === 'object' && !Array.isArray(value) && !Object.keys(value).length) {
      delete params[slot as Slot];
    }
  }
};

export const buildClientParams = (args: ReadonlyArray<unknown>, fields: FieldsConfig) => {
  const params: Params = {
    body: Object.create(null),
    headers: Object.create(null),
    path: Object.create(null),
    query: Object.create(null),
  };

  const map = buildKeyMap(fields);

  let config: FieldsConfig[number] | undefined;

  for (const [index, arg] of args.entries()) {
    if (fields[index]) {
      config = fields[index];
    }

    if (!config) {
      continue;
    }

    if ('in' in config) {
      if (config.key) {
        const field = map.get(config.key)!;
        const name = field.map || config.key;
        if (field.in) {
          (params[field.in] as Record<string, unknown>)[name] = arg;
        }
      } else {
        params.body = arg;
      }
    } else {
      for (const [key, value] of Object.entries(arg ?? {})) {
        const field = map.get(key);

        if (field) {
          if (field.in) {
            const name = field.map || key;
            (params[field.in] as Record<string, unknown>)[name] = value;
          } else {
            params[field.map] = value;
          }
        } else {
          const extra = extraPrefixes.find(([prefix]) => key.startsWith(prefix));

          if (extra) {
            const [prefix, slot] = extra;
            (params[slot] as Record<string, unknown>)[key.slice(prefix.length)] = value;
          } else if ('allowExtra' in config && config.allowExtra) {
            for (const [slot, allowed] of Object.entries(config.allowExtra)) {
              if (allowed) {
                (params[slot as Slot] as Record<string, unknown>)[key] = value;
                break;
              }
            }
          }
        }
      }
    }
  }

  stripEmptySlots(params);

  return params;
};
