/* eslint-disable no-underscore-dangle */
import JoyCon from "joycon";

type ValueType = "string" | "boolean" | "number" | "any";

export type Schema = {
  [optionName: string]: ValueType;
};

/**
 * Handler for a CLI command. Given `Options`, it asynchronously returns a `Result`
 */
export type Handler<Options extends object, Result> = (
  options: Options
) => Promise<Result>;

/**
 * A handler decorator, that given a handler will return a new handler, with some extra functionality
 */
export type Wrapper = <Options extends object, Result>(
  handler: Handler<Options, Result>,
  schema?: Schema
) => Handler<Options, Result>;

/**
 * Processes default options behavior:
 * - removes `--` option as we never use it
 * - file options (e.g. hypertune.config.js, hypertune.json, hypertune key in package.json)
 * - environment variable options (e.g. HYPERTUNE_TOKEN)
 * - any options passed in to the returned handler (i.e. CLI args)
 */
// eslint-disable-next-line func-style
export const withOtherOptionSources: Wrapper = (handler, schema) => {
  // In ascending priority order
  // i.e. if an option is defined by wrapper[1], the same option defined by wrapper[0] will be ignored
  // Passing in arguments to the returned handler has the highest priority
  const wrappers = [
    withoutDoubleDashOption,
    withFileOptions,
    withEnvVarOptions,
  ];

  let wrapped = handler;
  for (const wrapper of wrappers) {
    wrapped = wrapper(wrapped, schema);
  }
  return wrapped;
};

// eslint-disable-next-line func-style
const withoutDoubleDashOption: Wrapper = (handler) => {
  return (options) => {
    if (typeof options === "object" && options !== null && "--" in options) {
      // eslint-disable-next-line no-param-reassign
      delete options["--"];
    }
    return handler(options);
  };
};

// eslint-disable-next-line func-style
const withFileOptions: Wrapper = (handler) => {
  return async (options) => {
    const joycon = new JoyCon({
      files: [
        "hypertune.config.js",
        "hypertune.config.cjs",
        "hypertune.json",
        "package.json",
      ],
      packageKey: "hypertune",
    });

    const res = await joycon.load();

    if (typeof res.data === "object" && res.data !== null) {
      return handler(mergeOptions(res.data, options));
    }
    if (res.path) {
      console.warn(
        `Warning: Ignoring hypertune config at ${res.path}, as couldn't read it as a JavaScript object`
      );
    }

    return handler(options);
  };
};

// eslint-disable-next-line func-style
const withEnvVarOptions: Wrapper = (handler, schema) => {
  return (options) => {
    // eslint-disable-next-line no-constant-binary-expression
    if (typeof process !== undefined && process.env) {
      const envOptions = Object.fromEntries(
        Object.entries(process.env)
          .filter(([k]) => /^(.*_)?HYPERTUNE_/.test(k))
          .map(([k, v]) => [envNameToOptionName(k), v])
          .map(([k, v]) => [
            k,
            schema && schema[k as string]
              ? parseOptionValueWithSchema(schema[k as string], v as string)
              : v,
          ])
      );

      return handler(mergeOptions(envOptions, options));
    }

    return handler(options);
  };
};

function mergeOptions<T extends object>(
  options: object,
  overridingOptions: T
): T {
  const commonKeys = Object.keys(options).filter(
    (k) => k in overridingOptions
  ) as (keyof T)[];
  commonKeys.forEach((k) => {
    console.warn(
      `Warning: option "${String(
        k
      )}" defined multiple times, using value ${JSON.stringify(
        overridingOptions[k]
      )}`
    );
  });
  return { ...options, ...overridingOptions };
}

/**
 * Adds options validation to a handler based on a Zod schema.
 *
 * As Zod schemas can specify defaults, also changes the input type to what is actually required (e.g. okay not to provide something where it has a default).
 */
export function withValidation<Options extends object, HandlerResult>(
  schema: Schema,
  handler: Handler<Options, HandlerResult>
): Handler<Options, HandlerResult> {
  return (options) => {
    Object.entries(options).forEach(([option, value]) => {
      if (!(option in schema)) {
        console.warn(`Warning: Ignoring unrecognized option ${option}`);
        return;
      }
      switch (schema[option as string]) {
        case "any":
          return;
        case "string":
          if (typeof value !== "string") {
            throw new Error(
              `Option "${option}" must be a string, but ${typeof value} was provided`
            );
          }
          return;
        case "boolean":
          if (typeof value !== "boolean") {
            throw new Error(
              `Option "${option}" must be a boolean, but ${typeof value} was provided`
            );
          }
          return;
        case "number":
          if (typeof value !== "number") {
            throw new Error(
              `Option "${option}" must be a number, but ${typeof value} was provided`
            );
          }
          return;

        default:
          throw new Error(
            `Unexpected option type "${schema[option as string]}" for option "${option}"`
          );
      }
    });

    return handler(options);
  };
}

export function throwIfOptionIsUndefined<T>(
  optionName: string,
  value: T | undefined
): T {
  if (value === undefined) {
    throw new Error(
      `${optionName}: Missing required argument. Set it in your hypertune config (such as hypertune.json) as ${optionName}, use the ${optionNameToCliFlag(
        optionName
      )} argument, or the ${optionNameToEnvName(
        optionName
      )} environment variable.`
    );
  }
  return value;
}

export function parseOptionValueWithSchema(
  valueType: ValueType,
  value: string
): number | boolean | string {
  switch (valueType) {
    case "boolean": {
      switch (value.toLowerCase().trim()) {
        case "1":
        case "yes":
        case "true":
          return true;
        case "0":
        case "no":
        case "false":
          return false;
        default:
          return value;
      }
    }
    case "number": {
      if (/^-?\d+\.?\d*$/.test(value)) {
        return Number(value);
      }
      return value;
    }
    default:
      return value;
  }
}

/**
 * @example envNameToOptionName("NEXT_PUBLIC_HYPERTUNE_OUTPUT_FILE_PATH") == "outputFilePath"
 */
function envNameToOptionName(envName: string): string {
  const afterPrefix = envName.replace(/^(.*?_?)HYPERTUNE_/, "");
  return afterPrefix
    .toLowerCase()
    .replace(/_+(.)/g, (_, char: string) => char.toUpperCase());
}

/**
 * @example optionNameToEnvName("outputFilePath") == "HYPERTUNE_OUTPUT_FILE_PATH"
 */
function optionNameToEnvName(optionName: string): string {
  return `HYPERTUNE_${optionName
    .replace(/[A-Z]/g, (letter) => `_${letter}`)
    .toUpperCase()}`;
}

/**
 * @example optionNameToCliFlag("outputFilePath") == "--outputFilePath"
 */
function optionNameToCliFlag(optionName: string): string {
  return `--${optionName}`;
}
