import { parseRef } from "@redocly/openapi-core/lib/ref-utils.js";
import ts from "typescript";
import {
  BOOLEAN,
  NEVER,
  NULL,
  NUMBER,
  QUESTION_TOKEN,
  STRING,
  UNDEFINED,
  UNKNOWN,
  addJSDocComment,
  oapiRef,
  tsEnum,
  tsIntersection,
  tsIsPrimitive,
  tsLiteral,
  tsModifiers,
  tsNullable,
  tsOmit,
  tsPropertyIndex,
  tsRecord,
  tsUnion,
  tsWithRequired,
} from "../lib/ts.js";
import {
  createDiscriminatorProperty,
  createRef,
  getEntries,
} from "../lib/utils.js";
import {
  ReferenceObject,
  SchemaObject,
  TransformNodeOptions,
} from "../types.js";

/**
 * Transform SchemaObject nodes (4.8.24)
 * @see https://spec.openapis.org/oas/v3.1.0#schema-object
 */
export default function transformSchemaObject(
  schemaObject: SchemaObject | ReferenceObject,
  options: TransformNodeOptions,
): ts.TypeNode {
  const type = transformSchemaObjectWithComposition(schemaObject, options);
  if (typeof options.ctx.postTransform === "function") {
    const postTransformResult = options.ctx.postTransform(type, options);
    if (postTransformResult) {
      return postTransformResult;
    }
  }
  return type;
}

/**
 * Transform SchemaObjects
 */
export function transformSchemaObjectWithComposition(
  schemaObject: SchemaObject | ReferenceObject,
  options: TransformNodeOptions,
): ts.TypeNode {
  /**
   * Unexpected types & edge cases
   */

  // missing/falsy type returns `never`
  if (!schemaObject) {
    return NEVER;
  }
  // `true` returns `unknown` (this exists, but is untyped)
  if ((schemaObject as unknown) === true) {
    return UNKNOWN;
  }
  // for any other unexpected type, throw error
  if (Array.isArray(schemaObject) || typeof schemaObject !== "object") {
    throw new Error(
      `Expected SchemaObject, received ${
        Array.isArray(schemaObject) ? "Array" : typeof schemaObject
      }`,
    );
  }

  /**
   * ReferenceObject
   */
  if ("$ref" in schemaObject) {
    return oapiRef(schemaObject.$ref);
  }

  /**
   * const (valid for any type)
   */
  if (schemaObject.const !== null && schemaObject.const !== undefined) {
    return tsLiteral(schemaObject.const);
  }

  /**
   * enum (non-objects)
   * note: enum is valid for any type, but for objects, handle in oneOf below
   */
  if (
    Array.isArray(schemaObject.enum) &&
    (!("type" in schemaObject) || schemaObject.type !== "object") &&
    !("properties" in schemaObject) &&
    !("additionalProperties" in schemaObject)
  ) {
    // hoist enum to top level if string/number enum and option is enabled
    if (
      options.ctx.enum &&
      schemaObject.enum.every(
        (v) => typeof v === "string" || typeof v === "number",
      )
    ) {
      let enumName = parseRef(options.path ?? "").pointer.join("/");
      // allow #/components/schemas to have simpler names
      enumName = enumName.replace("components/schemas", "");
      const metadata = schemaObject.enum.map((_, i) => ({
        name: schemaObject["x-enum-varnames"]?.[i],
        description: schemaObject["x-enum-descriptions"]?.[i],
      }));
      const enumType = tsEnum(
        enumName,
        schemaObject.enum as (string | number)[],
        metadata,
        { export: true, readonly: options.ctx.immutable },
      );
      options.ctx.injectFooter.push(enumType);
      return ts.factory.createTypeReferenceNode(enumType.name);
    }
    return tsUnion(schemaObject.enum.map(tsLiteral));
  }

  /**
   * Object + composition (anyOf/allOf/oneOf) types
   */

  /** Collect oneOf/allOf/anyOf with Omit<> for discriminators */
  function collectCompositions(
    items: (SchemaObject | ReferenceObject)[],
    required?: string[],
  ): ts.TypeNode[] {
    const output: ts.TypeNode[] = [];
    for (const item of items) {
      let itemType: ts.TypeNode;
      // if this is a $ref, use WithRequired<X, Y> if parent specifies required properties
      // (but only for valid keys)
      if ("$ref" in item) {
        itemType = transformSchemaObject(item, options);

        const resolved = options.ctx.resolve<SchemaObject>(item.$ref);
        if (
          resolved &&
          typeof resolved === "object" &&
          "properties" in resolved
        ) {
          // don’t try and make keys required if the $ref doesn’t have them
          const validRequired = (required ?? []).filter(
            (key) => !!resolved.properties![key],
          );
          if (validRequired.length) {
            itemType = tsWithRequired(
              itemType,
              validRequired,
              options.ctx.injectFooter,
            );
          }
        }
      }
      // otherwise, if this is a schema object, combine parent `required[]` with its own, if any
      else {
        const itemRequired = [...(required ?? [])];
        if (typeof item === "object" && Array.isArray(item.required)) {
          itemRequired.push(...item.required);
        }
        itemType = transformSchemaObject(
          { ...item, required: itemRequired },
          options,
        );
      }
      const discriminator =
        ("$ref" in item && options.ctx.discriminators[item.$ref]) ||
        (item as any).discriminator; // eslint-disable-line @typescript-eslint/no-explicit-any
      if (discriminator) {
        output.push(tsOmit(itemType, [discriminator.propertyName]));
      } else {
        output.push(itemType);
      }
    }
    return output;
  }

  // compile final type
  let finalType: ts.TypeNode | undefined = undefined;

  // core + allOf: intersect
  const coreObjectType = transformSchemaObjectCore(schemaObject, options);
  const allOfType = collectCompositions(
    schemaObject.allOf ?? [],
    schemaObject.required,
  );
  if (coreObjectType || allOfType.length) {
    const allOf: ts.TypeNode | undefined = allOfType.length
      ? tsIntersection(allOfType)
      : undefined;
    finalType = tsIntersection([
      ...(coreObjectType ? [coreObjectType] : []),
      ...(allOf ? [allOf] : []),
    ]);
  }
  // anyOf: union
  // (note: this may seem counterintuitive, but as TypeScript’s unions are not true XORs, they mimic behavior closer to anyOf than oneOf)
  const anyOfType = collectCompositions(
    schemaObject.anyOf ?? [],
    schemaObject.required,
  );
  if (anyOfType.length) {
    finalType = tsUnion([...(finalType ? [finalType] : []), ...anyOfType]);
  }
  // oneOf: union (within intersection with other types, if any)
  const oneOfType = collectCompositions(
    schemaObject.oneOf ||
      ("type" in schemaObject &&
        schemaObject.type === "object" &&
        (schemaObject.enum as (SchemaObject | ReferenceObject)[])) ||
      [],
    schemaObject.required,
  );
  if (oneOfType.length) {
    // note: oneOf is the only type that may include primitives
    if (oneOfType.every(tsIsPrimitive)) {
      finalType = tsUnion([...(finalType ? [finalType] : []), ...oneOfType]);
    } else {
      finalType = tsIntersection([
        ...(finalType ? [finalType] : []),
        tsUnion(oneOfType),
      ]);
    }
  }

  // if final type could be generated, return intersection of all members
  if (finalType) {
    // deprecated nullable
    if (schemaObject.nullable) {
      return tsNullable([finalType]);
    }
    return finalType;
  }
  // otherwise fall back to unknown type (or related variants)
  else {
    // fallback: unknown
    if (!("type" in schemaObject)) {
      return UNKNOWN;
    }

    // if no type could be generated, fall back to “empty object” type
    return tsRecord(STRING, options.ctx.emptyObjectsUnknown ? UNKNOWN : NEVER);
  }
}

/**
 * Handle SchemaObject minus composition (anyOf/allOf/oneOf)
 */
function transformSchemaObjectCore(
  schemaObject: SchemaObject,
  options: TransformNodeOptions,
): ts.TypeNode | undefined {
  if ("type" in schemaObject && schemaObject.type) {
    // primitives
    // type: null
    if (schemaObject.type === "null") {
      return NULL;
    }
    // type: string
    if (schemaObject.type === "string") {
      return STRING;
    }
    // type: number / type: integer
    if (schemaObject.type === "number" || schemaObject.type === "integer") {
      return NUMBER;
    }
    // type: boolean
    if (schemaObject.type === "boolean") {
      return BOOLEAN;
    }

    // type: array (with support for tuples)
    if (schemaObject.type === "array") {
      // default to `unknown[]`
      let itemType: ts.TypeNode = UNKNOWN;
      // tuple type
      if (schemaObject.prefixItems || Array.isArray(schemaObject.items)) {
        const prefixItems =
          schemaObject.prefixItems ??
          (schemaObject.items as (SchemaObject | ReferenceObject)[]);
        itemType = ts.factory.createTupleTypeNode(
          prefixItems.map((item) => transformSchemaObject(item, options)),
        );
      }
      // standard array type
      else if (schemaObject.items) {
        itemType = transformSchemaObject(schemaObject.items, options);
      }

      const min: number =
        typeof schemaObject.minItems === "number" && schemaObject.minItems >= 0
          ? schemaObject.minItems
          : 0;
      const max: number | undefined =
        typeof schemaObject.maxItems === "number" &&
        schemaObject.maxItems >= 0 &&
        min <= schemaObject.maxItems
          ? schemaObject.maxItems
          : undefined;
      const estimateCodeSize =
        typeof max !== "number" ? min : (max * (max + 1) - min * (min - 1)) / 2;
      if (
        options.ctx.arrayLength &&
        (min !== 0 || max !== undefined) &&
        estimateCodeSize < 30 // "30" is an arbitrary number but roughly around when TS starts to struggle with tuple inference in practice
      ) {
        // if maxItems is set, then return a union of all permutations of possible tuple types
        if ((schemaObject.maxItems as number) > 0) {
          const members: ts.TypeNode[] = [];
          // populate 1 short of min …
          for (let i = 0; i <= (max ?? 0) - min; i++) {
            const elements: ts.TypeNode[] = [];
            for (let j = min; j < i + min; j++) {
              elements.push(itemType);
            }
            members.push(ts.factory.createTupleTypeNode(elements));
          }
          return tsUnion(members);
        }
        // if maxItems not set, then return a simple tuple type the length of `min`
        else {
          const elements: ts.TypeNode[] = [];
          for (let i = 0; i < min; i++) {
            elements.push(itemType);
          }
          elements.push(
            ts.factory.createRestTypeNode(
              ts.factory.createArrayTypeNode(itemType),
            ),
          );
          return ts.factory.createTupleTypeNode(elements);
        }
      }

      return ts.isTupleTypeNode(itemType)
        ? itemType
        : ts.factory.createArrayTypeNode(itemType); // wrap itemType in array type, but only if not a tuple already
    }

    // polymorphic, or 3.1 nullable
    if (Array.isArray(schemaObject.type) && !Array.isArray(schemaObject)) {
      // skip any primitive types that appear in oneOf as well
      let uniqueTypes: ts.TypeNode[] = [];
      if (Array.isArray(schemaObject.oneOf)) {
        for (const t of schemaObject.type) {
          if (
            (t === "boolean" ||
              t === "string" ||
              t === "number" ||
              t === "integer" ||
              t === "null") &&
            schemaObject.oneOf.find(
              (o) => typeof o === "object" && "type" in o && o.type === t,
            )
          ) {
            continue;
          }
          uniqueTypes.push(
            t === "null" || t === null
              ? NULL
              : transformSchemaObject(
                  { ...schemaObject, type: t, oneOf: undefined }, // don’t stack oneOf transforms
                  options,
                ),
          );
        }
      } else {
        uniqueTypes = schemaObject.type.map((t) =>
          t === "null" || t === null
            ? NULL
            : transformSchemaObject({ ...schemaObject, type: t }, options),
        );
      }
      return tsUnion(uniqueTypes);
    }
  }

  // type: object
  const coreObjectType: ts.TypeElement[] = [];

  // discriminatorss: explicit mapping on schema object
  for (const k of ["oneOf", "allOf", "anyOf"] as const) {
    if (!schemaObject[k]) {
      continue;
    }
    // for all magic inheritance, we will have already gathered it into
    // ctx.discriminators. But stop objects from referencing their own
    // discriminator meant for children (!schemaObject.discriminator)
    const discriminator =
      !schemaObject.discriminator && options.ctx.discriminators[options.path!];
    if (discriminator) {
      coreObjectType.unshift(
        createDiscriminatorProperty(discriminator, {
          path: options.path!,
          readonly: options.ctx.immutable,
        }),
      );
      break;
    }
  }

  if (
    ("properties" in schemaObject &&
      schemaObject.properties &&
      Object.keys(schemaObject.properties).length) ||
    ("additionalProperties" in schemaObject &&
      schemaObject.additionalProperties) ||
    ("$defs" in schemaObject && schemaObject.$defs)
  ) {
    // properties
    if (Object.keys(schemaObject.properties ?? {}).length) {
      for (const [k, v] of getEntries(
        schemaObject.properties ?? {},
        options.ctx,
      )) {
        if (typeof v !== "object" || Array.isArray(v)) {
          throw new Error(
            `${
              options.path
            }: invalid property ${k}. Expected Schema Object, got ${
              Array.isArray(v) ? "Array" : typeof v
            }`,
          );
        }

        // handle excludeDeprecated option
        if (options.ctx.excludeDeprecated) {
          const resolved =
            "$ref" in v ? options.ctx.resolve<SchemaObject>(v.$ref) : v;
          if (resolved?.deprecated) {
            continue;
          }
        }
        let optional =
          schemaObject.required?.includes(k) ||
          ("default" in v &&
            options.ctx.defaultNonNullable &&
            !options.path?.includes("parameters")) // parameters can’t be required, even with defaults
            ? undefined
            : QUESTION_TOKEN;
        let type =
          "$ref" in v
            ? oapiRef(v.$ref)
            : transformSchemaObject(v, {
                ...options,
                path: createRef([options.path ?? "", k]),
              });

        if (typeof options.ctx.transform === "function") {
          const result = options.ctx.transform(v, options);
          if (result) {
            if ("schema" in result) {
              type = result.schema;
              optional = result.questionToken ? QUESTION_TOKEN : optional;
            } else {
              type = result;
            }
          }
        }

        const property = ts.factory.createPropertySignature(
          /* modifiers     */ tsModifiers({
            readonly:
              options.ctx.immutable || ("readOnly" in v && !!v.readOnly),
          }),
          /* name          */ tsPropertyIndex(k),
          /* questionToken */ optional,
          /* type          */ type,
        );
        addJSDocComment(v, property);
        coreObjectType.push(property);
      }
    }

    // $defs
    if (
      schemaObject.$defs &&
      typeof schemaObject.$defs === "object" &&
      Object.keys(schemaObject.$defs).length
    ) {
      const defKeys: ts.TypeElement[] = [];
      for (const [k, v] of Object.entries(schemaObject.$defs)) {
        const property = ts.factory.createPropertySignature(
          /* modifiers    */ tsModifiers({
            readonly:
              options.ctx.immutable || ("readonly" in v && !!v.readOnly),
          }),
          /* name          */ tsPropertyIndex(k),
          /* questionToken */ undefined,
          /* type          */ transformSchemaObject(v, {
            ...options,
            path: createRef([options.path ?? "", "$defs", k]),
          }),
        );
        addJSDocComment(v, property);
        defKeys.push(property);
      }
      coreObjectType.push(
        ts.factory.createPropertySignature(
          /* modifiers     */ undefined,
          /* name          */ tsPropertyIndex("$defs"),
          /* questionToken */ undefined,
          /* type          */ ts.factory.createTypeLiteralNode(defKeys),
        ),
      );
    }

    // additionalProperties
    if (schemaObject.additionalProperties || options.ctx.additionalProperties) {
      const hasExplicitAdditionalProperties =
        typeof schemaObject.additionalProperties === "object" &&
        Object.keys(schemaObject.additionalProperties).length;
      let addlType = hasExplicitAdditionalProperties
        ? transformSchemaObject(
            schemaObject.additionalProperties as SchemaObject,
            options,
          )
        : UNKNOWN;
      // allow for `| undefined`, at least until https://github.com/microsoft/TypeScript/issues/4196 is resolved
      if (addlType.kind !== ts.SyntaxKind.UnknownKeyword) {
        addlType = tsUnion([addlType, UNDEFINED]);
      }
      coreObjectType.push(
        ts.factory.createIndexSignature(
          /* modifiers  */ tsModifiers({
            readonly: options.ctx.immutable,
          }),
          /* parameters */ [
            ts.factory.createParameterDeclaration(
              /* modifiers      */ undefined,
              /* dotDotDotToken */ undefined,
              /* name           */ ts.factory.createIdentifier("key"),
              /* questionToken  */ undefined,
              /* type           */ STRING,
            ),
          ],
          /* type       */ addlType,
        ),
      );
    }
  }

  return coreObjectType.length
    ? ts.factory.createTypeLiteralNode(coreObjectType)
    : undefined;
}
