#!/usr/bin/env node

import fs from "node:fs";
import path from "node:path";
import { performance } from "node:perf_hooks";
import { createConfig, findConfig, loadConfig } from "@redocly/openapi-core";
import parser from "yargs-parser";
import openapiTS, { astToString, COMMENT_HEADER, c, error, formatTime, warn } from "../dist/index.mjs";

const HELP = `Usage
  $ openapi-typescript [input] [options]

Options
  --help                     Display this
  --version                  Display the version
  --redocly [path], -c       Specify path to Redocly config (default: redocly.yaml)
  --output, -o               Specify output file (if not specified in redocly.yaml)
  --enum                     Export true TS enums instead of unions
  --enum-values              Export enum values as arrays
  --conditional-enums        Only generate true TS enums when enum metadata is available (default: false)
  --dedupe-enums             Dedupe enum types when \`--enum=true\` is set
  --check                    Check that the generated types are up-to-date. (default: false)
  --export-type, -t          Export top-level \`type\` instead of \`interface\`
  --immutable                Generate readonly types
  --additional-properties    Treat schema objects as if \`additionalProperties: true\` is set
  --empty-objects-unknown    Generate \`unknown\` instead of \`Record<string, never>\` for empty objects
  --default-non-nullable     Set to \`false\` to ignore default values when generating non-nullable types
  --properties-required-by-default
                             Treat schema objects as if \`required\` is set to all properties by default
  --array-length             Generate tuples using array minItems / maxItems
  --path-params-as-types     Convert paths to template literal types
  --alphabetize              Sort object keys alphabetically
  --exclude-deprecated       Exclude deprecated types
  --root-types (optional)    Export schemas types at root level
  --root-types-no-schema-prefix (optional)
                             Do not add "Schema" prefix to types at the root level (should only be used with --root-types)
  --root-types-keep-casing   Keep casing of root types (should only be used with --root-types)
  --make-paths-enum          Generate ApiPaths enum for all paths
  --read-write-markers       Generate $Read/$Write markers for readOnly/writeOnly properties
`;

const OUTPUT_FILE = "FILE";
const OUTPUT_STDOUT = "STDOUT";
const CWD = new URL(`file://${process.cwd()}/`);
const REDOC_CONFIG_KEY = "x-openapi-ts";

const timeStart = performance.now();

const [, , ...args] = process.argv;
if (args.includes("-ap")) {
  errorAndExit(`The -ap alias has been deprecated. Use "--additional-properties" instead.`);
}
if (args.includes("--immutable-types")) {
  errorAndExit(`The --immutable-types flag has been renamed to "--immutable".`);
}
if (args.includes("--support-array-length")) {
  errorAndExit(`The --support-array-length flag has been renamed to "--array-length".`);
}
if (args.includes("-it")) {
  errorAndExit(`The -it alias has been deprecated. Use "--immutable-types" instead.`);
}
if (args.includes("--redoc")) {
  errorAndExit(`The --redoc config flag has been renamed to "--redocly" (or -c as shorthand).`);
}
if (args.includes("--root-types-no-schema-prefix") && !args.includes("--root-types")) {
  // biome-ignore lint/suspicious/noConsole: this is a CLI
  console.warn("--root-types-no-schema-prefix has no effect without --root-types flag");
}
if (args.includes("--root-types-keep-casing") && !args.includes("--root-types")) {
  // biome-ignore lint/suspicious/noConsole: this is a CLI
  console.warn("--root-types-keep-casing has no effect without --root-types flag");
}

const flags = parser(args, {
  boolean: [
    "additionalProperties",
    "alphabetize",
    "arrayLength",
    "contentNever",
    "defaultNonNullable",
    "propertiesRequiredByDefault",
    "emptyObjectsUnknown",
    "enum",
    "enumValues",
    "conditionalEnums",
    "dedupeEnums",
    "check",
    "excludeDeprecated",
    "exportType",
    "help",
    "immutable",
    "pathParamsAsTypes",
    "rootTypes",
    "rootTypesNoSchemaPrefix",
    "rootTypesKeepCasing",
    "makePathsEnum",
    "generatePathParams",
    "readWriteMarkers",
  ],
  string: ["output", "redocly"],
  alias: {
    redocly: ["c"],
    exportType: ["t"],
    output: ["o"],
  },
});

/**
 * Normalize the output path into a file URL.
 * @param {string} output - The output path to be transformed.
 * @returns {URL} The transformed file URL.
 */
function normalizeOutput(output) {
  if (path.isAbsolute(output)) {
    return new URL(`file://${output}`);
  }
  return new URL(output, CWD);
}

/**
 * Check if the generated types are up-to-date.
 * @param {string} current - The current generated types.
 * @param {URL} outputPath - The path to the output file.
 */
function checkStaleOutput(current, outputPath) {
  if (flags.check) {
    const previous = fs.readFileSync(outputPath, "utf8");
    if (current === previous) {
      process.exit(0);
    } else {
      error("Generated types are not up-to-date!");
      process.exit(1);
    }
  }
}

/**
 * @param {string | URL} schema
 * @param {@type import('@redocly/openapi-core').Config} redocly
 */
async function generateSchema(schema, { redocly, silent = false }) {
  return `${COMMENT_HEADER}${astToString(
    await openapiTS(schema, {
      additionalProperties: flags.additionalProperties,
      alphabetize: flags.alphabetize,
      arrayLength: flags.arrayLength,
      contentNever: flags.contentNever,
      propertiesRequiredByDefault: flags.propertiesRequiredByDefault,
      defaultNonNullable: flags.defaultNonNullable,
      emptyObjectsUnknown: flags.emptyObjectsUnknown,
      enum: flags.enum,
      enumValues: flags.enumValues,
      conditionalEnums: flags.conditionalEnums,
      dedupeEnums: flags.dedupeEnums,
      excludeDeprecated: flags.excludeDeprecated,
      exportType: flags.exportType,
      immutable: flags.immutable,
      pathParamsAsTypes: flags.pathParamsAsTypes,
      rootTypes: flags.rootTypes,
      rootTypesNoSchemaPrefix: flags.rootTypesNoSchemaPrefix,
      rootTypesKeepCasing: flags.rootTypesKeepCasing,
      makePathsEnum: flags.makePathsEnum,
      generatePathParams: flags.generatePathParams,
      readWriteMarkers: flags.readWriteMarkers,
      redocly,
      silent,
    }),
  )}`;
}

/** pretty-format error message but also throw */
function errorAndExit(message) {
  error(message);
  throw new Error(message);
}

function done(input, output, time) {
  // final console output
  // biome-ignore lint/suspicious/noConsole: this is a CLI
  console.log(`🚀 ${c.green(`${input} → ${c.bold(output)}`)} ${c.dim(`[${formatTime(time)}]`)}`);
}

function findRedocConfigPath() {
  if (!flags.redocly) {
    return findConfig();
  }
  const explicitPath = path.resolve(flags.redocly);
  if (!fs.existsSync(explicitPath)) {
    return undefined;
  }
  const stat = fs.statSync(explicitPath);
  return stat.isDirectory() ? findConfig(explicitPath) : explicitPath;
}

async function main() {
  if ("help" in flags) {
    // biome-ignore lint/suspicious/noConsole: this is a CLI
    console.info(HELP);
    process.exit(0);
  }
  const packageJSON = JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf8"));
  if ("version" in flags) {
    // biome-ignore lint/suspicious/noConsole: this is a CLI
    console.info(`v${packageJSON.version}`);
    process.exit(0);
  }

  const outputType = flags.output ? OUTPUT_FILE : OUTPUT_STDOUT; // FILE or STDOU
  if (outputType !== OUTPUT_STDOUT) {
    // biome-ignore lint/suspicious/noConsole: this is a CLI
    console.info(`✨ ${c.bold(`openapi-typescript ${packageJSON.version}`)}`);
  }

  const input = flags._[0];

  const redocConfigPath = findRedocConfigPath();
  if (flags.redocly && !redocConfigPath) {
    errorAndExit(`Redocly config not found at: ${flags.redocly}`);
  }
  const redocly = redocConfigPath
    ? await loadConfig({ configPath: redocConfigPath })
    : await createConfig({}, { extends: ["minimal"] });

  // handle Redoc APIs
  const hasRedoclyApis = Object.keys(redocly?.apis ?? {}).length > 0;
  if (hasRedoclyApis) {
    if (input) {
      warn("APIs are specified both in Redocly Config and CLI argument. Only using Redocly config.");
    }
    await Promise.all(
      Object.entries(redocly.apis).map(async ([name, api]) => {
        let configRoot = CWD;
        if (redocly.configFile) {
          // note: this will be absolute if --redoc is passed; otherwise, relative
          configRoot = path.isAbsolute(redocly.configFile)
            ? new URL(`file://${redocly.configFile}`)
            : new URL(redocly.configFile, `file://${process.cwd()}/`);
        }
        if (!api[REDOC_CONFIG_KEY]?.output) {
          errorAndExit(
            `API ${name} is missing an \`${REDOC_CONFIG_KEY}.output\` key. See https://openapi-ts.dev/cli/#multiple-schemas.`,
          );
        }
        const result = await generateSchema(new URL(api.root, configRoot), { redocly });
        const outFile = new URL(api[REDOC_CONFIG_KEY].output, configRoot);
        checkStaleOutput(result, outFile);
        fs.mkdirSync(new URL(".", outFile), { recursive: true });
        fs.writeFileSync(outFile, result, "utf8");
        done(name, api[REDOC_CONFIG_KEY].output, performance.now() - timeStart);
      }),
    );
  }

  // handle stdin
  else if (!input) {
    const result = await generateSchema(process.stdin, {
      redocly,
      silent: outputType === OUTPUT_STDOUT,
    });
    if (outputType === OUTPUT_STDOUT) {
      // if stdout, (still) don’t log anything to console!
      process.stdout.write(result);
    } else {
      const outFile = normalizeOutput(flags.output);
      checkStaleOutput(result, outFile);
      fs.mkdirSync(new URL(".", outFile), { recursive: true });
      fs.writeFileSync(outFile, result, "utf8");
      done("stdin", flags.output, performance.now() - timeStart);
    }
  }

  // handle single file
  else {
    // throw error on glob
    if (input.includes("*")) {
      errorAndExit(
        "Globbing has been deprecated in favor of redocly.yaml’s `apis` keys. See https://openapi-ts.dev/cli/#multiple-schemas",
      );
    }
    const result = await generateSchema(new URL(input, CWD), {
      redocly,
      silent: outputType === OUTPUT_STDOUT,
    });
    if (outputType === OUTPUT_STDOUT) {
      // if stdout, (still) don’t log anything to console!
      process.stdout.write(result);
    } else {
      const outFile = normalizeOutput(flags.output);
      checkStaleOutput(result, outFile);
      fs.mkdirSync(new URL(".", outFile), { recursive: true });
      fs.writeFileSync(outFile, result, "utf8");
      done(input, flags.output, performance.now() - timeStart);
    }
  }
}

main();
