import { setTimeout } from 'timers/promises';
import debug from 'debug';
import stringify from 'safe-stable-stringify';
import { type JsonValue, type Tagged } from 'type-fest';

import { components, type Logger } from '../types/index.js';

export const defaultLoggersByComponent = Object.fromEntries(
  components.map(
    (name) =>
      [
        name,
        (() => {
          const debugLogger = debug(`@ethanresnick/cache:${name}`);
          return (_, level, message, data) => {
            debugLogger(`(${level}) ${message} %O`, data);
          };
        })(),
      ] as const,
  ),
) satisfies Record<string, Logger> as {
  [K in (typeof components)[number]]: Logger;
};

/**
 * Preserves a type's tupleness during a map operation.
 */
export function mapTuple<T extends readonly unknown[], U>(
  arr: T,
  fn: (it: T[number]) => U,
) {
  return arr.map(fn) as { [K in keyof T]: U };
}

/**
 * Runs an operation and retries it on failure, up to the provided
 * number of times, and waiting between each retry, with the amount of time
 * to wait being determined by the provided backoff function, which receives
 * the number of tries so far (starting at 1).
 */
export function withRetries<T>(
  operation: () => Promise<T>,
  attemptsToMake: number,
  backoff: (i: number) => number,
) {
  const run = async (
    attemptNumber: number,
    lastError: Error | undefined,
  ): Promise<T> => {
    if (attemptNumber > attemptsToMake) {
      // Will not be undefined as long as attemptsToMake is >= 1.
      throw lastError!;
    }

    return operation().catch(async (e) => {
      await delay(backoff(attemptNumber));
      return run(attemptNumber + 1, e);
    });
  };

  return async () => run(1, undefined);
}

/**
 * Faster, more-type-safe version of lodash's zip, just for pairs.
 * If the arrays are not the same length, the longer one will be truncated!
 */
export const zip2 = <T, U>(a: T[], b: U[]): [T, U][] => {
  const res = [];
  for (let i = 0; i < Math.min(a.length, b.length); i++) {
    res.push([a[i], b[i]] as [T, U]);
  }

  return res;
};

/**
 * Returns a promise that resolves after `ms` milliseconds.
 *
 * @param {number} ms Number of miliseconds before resolution.
 */
export const delay = setTimeout;

/**
 * Converts a value to JSON, while preserving its type for future inspection.
 *
 * Sometimes, we need to stringify a value (e.g., to use the string as a key),
 * but we'd still like to Typescript to track the original type that we
 * stringified, so that we can have type checking on the data we'll get back
 * if/when we JSON.parse the string later. That's what this `jsonStringify`
 * helper function does. See {@link jsonParse}.
 *
 * The order of object keys in the final string will be normalized, such that,
 * e.g., `{ a: 0, b: 0 }` and `{ b: 0, a: 0 }` produce the same string. This is
 * often useful when using the resulting string as a cache key.
 *
 * NB: If we didn't constrain `T` to JsonValue, then the proper return type
 * would have to be `JsonOf<Jsonify<T>>` (where `Jsonify` comes from type-fest),
 * but Jsonify trips up Typescript in some corner cases, so we stick with this
 * more limited type.
 *
 * @param it The value to stringify.
 */
export function jsonStringify<T extends JsonValue>(it: Readonly<T>) {
  return stringify(it) as JsonOf<T>;
}

/**
 * Parses the JSON, and returns its original type, for JSON generated by
 * {@link jsonStringify}.
 */
export function jsonParse<T extends JsonOf<unknown>>(it: T) {
  return JSON.parse(it) as (typeof it)[typeof meta];
}

declare const meta: unique symbol;
export type JsonOf<T> = Tagged<string, 'JSON'> & { readonly [meta]: T };

// Highly incomplete code for interoperating with raw HTTP responses,
// by doing all the necessary header parsing and age inference.
// import { FullResponse } from 'request-promise-native'
// import { ProducerResult } from './types'
//
// function httpProducerResult<T extends FullResponse>(
//   resp: T,
//   fetchTimeMs: number,
// ): ProducerResult<T, { etag: string }, string> {
//   const { vary } = resp.headers.vary;
//   const varyHeaders = Array.isArray(vary) ? vary : vary ? [vary] : [];
//   const varyHeadersNormalized = varyHeaders.map((it) => it.trim());

//   return {
//     id: resp.request.uri.href,
//     // In proper HTTP, method (not just uri) is part of the cache key;
//     // we simulate that by treating method as a param name that's always varied on
//     vary: {
//       method: resp.request.method,
//       ...Object.fromEntries(
//         varyHeadersNormalized.map((header) => [
//           header,
//           resp.request.hasHeader(header)
//             ? resp.request.getHeader(header)
//             : null,
//         ]),
//       ),
//     },
//     content: resp,
//     // TODO, parse cache-control or expires w/ Date; use heuristics if missing.
//     maxAge: 100000000000,
//     validate: resp.headers.etag ? { etag: String(resp.headers.etag) } : {},
//     initialAge: Math.round((resp.headers.age || 0) + fetchTimeMs * 1000),
//   };
// }
