import { parseRef } from "@redocly/openapi-core/lib/ref-utils.js";
import ts, { LiteralTypeNode, TypeLiteralNode } from "typescript";

export const JS_PROPERTY_INDEX_RE = /^[A-Za-z_$][A-Za-z_$0-9]*$/;
export const JS_ENUM_INVALID_CHARS_RE = /[^A-Za-z_$0-9]+(.)?/g;
export const JS_PROPERTY_INDEX_INVALID_CHARS_RE = /[^A-Za-z_$0-9]+/g;

export const BOOLEAN = ts.factory.createKeywordTypeNode(
  ts.SyntaxKind.BooleanKeyword,
);
export const FALSE = ts.factory.createLiteralTypeNode(ts.factory.createFalse());
export const NEVER = ts.factory.createKeywordTypeNode(
  ts.SyntaxKind.NeverKeyword,
);
export const NULL = ts.factory.createLiteralTypeNode(ts.factory.createNull());
export const NUMBER = ts.factory.createKeywordTypeNode(
  ts.SyntaxKind.NumberKeyword,
);
export const QUESTION_TOKEN = ts.factory.createToken(
  ts.SyntaxKind.QuestionToken,
);
export const STRING = ts.factory.createKeywordTypeNode(
  ts.SyntaxKind.StringKeyword,
);
export const TRUE = ts.factory.createLiteralTypeNode(ts.factory.createTrue());
export const UNDEFINED = ts.factory.createKeywordTypeNode(
  ts.SyntaxKind.UndefinedKeyword,
);
export const UNKNOWN = ts.factory.createKeywordTypeNode(
  ts.SyntaxKind.UnknownKeyword,
);

const LB_RE = /\r?\n/g;
const COMMENT_RE = /\*\//g;

export interface AnnotatedSchemaObject {
  const?: unknown; // jsdoc without value
  default?: unknown; // jsdoc with value
  deprecated?: boolean; // jsdoc without value
  description?: string; // jsdoc with value
  enum?: unknown[]; // jsdoc without value
  example?: string; // jsdoc with value
  format?: string; // not jsdoc
  nullable?: boolean; // Node information
  summary?: string; // not jsdoc
  title?: string; // not jsdoc
  type?: string | string[]; // Type of node
}

/**
 * Preparing comments from fields
 * @see {comment} for output examples
 * @returns void if not comments or jsdoc format comment string
 */
export function addJSDocComment(
  schemaObject: AnnotatedSchemaObject,
  node: ts.PropertySignature,
): void {
  if (
    !schemaObject ||
    typeof schemaObject !== "object" ||
    Array.isArray(schemaObject)
  ) {
    return;
  }
  const output: string[] = [];

  // Not JSDoc tags: [title, format]
  if (schemaObject.title) {
    output.push(schemaObject.title.replace(LB_RE, "\n *     "));
  }
  if (schemaObject.summary) {
    output.push(schemaObject.summary.replace(LB_RE, "\n *     "));
  }
  if (schemaObject.format) {
    output.push(`Format: ${schemaObject.format}`);
  }

  // JSDoc tags without value
  // 'Deprecated' without value
  if (schemaObject.deprecated) {
    output.push("@deprecated");
  }

  // JSDoc tags with value
  const supportedJsDocTags = ["description", "default", "example"] as const;
  for (const field of supportedJsDocTags) {
    const allowEmptyString = field === "default" || field === "example";
    if (schemaObject[field] === undefined) {
      continue;
    }
    if (schemaObject[field] === "" && !allowEmptyString) {
      continue;
    }
    const serialized =
      typeof schemaObject[field] === "object"
        ? JSON.stringify(schemaObject[field], null, 2)
        : schemaObject[field];
    output.push(`@${field} ${String(serialized).replace(LB_RE, "\n *     ")}`);
  }

  // JSDoc 'Constant' without value
  if ("const" in schemaObject) {
    output.push("@constant");
  }

  // JSDoc 'Enum' with type
  if (schemaObject.enum) {
    let type = "unknown";
    if (Array.isArray(schemaObject.type)) {
      type = schemaObject.type.join("|");
    } else if (typeof schemaObject.type === "string") {
      type = schemaObject.type;
    }
    output.push(`@enum {${type}${schemaObject.nullable ? `|null` : ""}}`);
  }

  // attach comment if it has content

  if (output.length) {
    let text =
      output.length === 1
        ? `* ${output.join("\n")} `
        : `*
 * ${output.join("\n * ")}\n `;
    text = text.replace(COMMENT_RE, "*\\/"); // prevent inner comments from leaking

    ts.addSyntheticLeadingComment(
      /* node               */ node,
      /* kind               */ ts.SyntaxKind.MultiLineCommentTrivia, // note: MultiLine just refers to a "/* */" comment
      /* text               */ text,
      /* hasTrailingNewLine */ true,
    );
  }
}

/** Convert OpenAPI ref into TS indexed access node (ex: `components["schemas"]["Foo"]`) */
export function oapiRef(path: string): ts.TypeNode {
  const { pointer } = parseRef(path);
  if (pointer.length === 0) {
    throw new Error(`Error parsing $ref: ${path}. Is this a valid $ref?`);
  }
  let t: ts.TypeReferenceNode | ts.IndexedAccessTypeNode =
    ts.factory.createTypeReferenceNode(
      ts.factory.createIdentifier(String(pointer[0])),
    );
  if (pointer.length > 1) {
    for (let i = 1; i < pointer.length; i++) {
      t = ts.factory.createIndexedAccessTypeNode(
        t,
        ts.factory.createLiteralTypeNode(
          typeof pointer[i]! === "number"
            ? ts.factory.createNumericLiteral(pointer[i]!)
            : ts.factory.createStringLiteral(pointer[i]! as string),
        ),
      );
    }
  }
  return t;
}

export interface AstToStringOptions {
  fileName?: string;
  sourceText?: string;
  formatOptions?: ts.PrinterOptions;
}

/** Convert TypeScript AST to string */
export function astToString(
  ast: ts.Node | ts.Node[] | ts.TypeElement | ts.TypeElement[],
  options?: AstToStringOptions,
): string {
  const sourceFile = ts.createSourceFile(
    options?.fileName ?? "openapi-ts.ts",
    options?.sourceText ?? "",
    ts.ScriptTarget.ESNext,
    false,
    ts.ScriptKind.TS,
  );

  // @ts-expect-error it’s OK to overwrite statements once
  sourceFile.statements = ts.factory.createNodeArray(
    Array.isArray(ast) ? ast : [ast],
  );

  const printer = ts.createPrinter({
    newLine: ts.NewLineKind.LineFeed,
    removeComments: false,
    ...options?.formatOptions,
  });
  return printer.printFile(sourceFile);
}

/** Convert an arbitrary string to TS (assuming it’s valid) */
export function stringToAST(source: string): unknown[] {
  return ts.createSourceFile(
    /* fileName        */ "stringInput",
    /* sourceText      */ source,
    /* languageVersion */ ts.ScriptTarget.ESNext,
    /* setParentNodes  */ undefined,
    /* scriptKind      */ undefined,
  ).statements as any; // eslint-disable-line @typescript-eslint/no-explicit-any
}

/**
 * Deduplicate simple primitive types from an array of nodes
 * Note: won’t deduplicate complex types like objects
 */
export function tsDedupe(types: ts.TypeNode[]): ts.TypeNode[] {
  const encounteredTypes = new Set<number>();
  const filteredTypes: ts.TypeNode[] = [];
  for (const t of types) {
    // only mark for deduplication if this is not a const ("text" means it is a const)
    if (!("text" in ((t as LiteralTypeNode).literal ?? t))) {
      const { kind } = (t as LiteralTypeNode).literal ?? t;
      if (encounteredTypes.has(kind)) {
        continue;
      }
      if (tsIsPrimitive(t)) {
        encounteredTypes.add(kind);
      }
    }
    filteredTypes.push(t);
  }
  return filteredTypes;
}

/** Create a TS enum (with sanitized name and members) */
export function tsEnum(
  name: string,
  members: (string | number)[],
  metadata?: { name?: string; description?: string }[],
  options?: { readonly?: boolean; export?: boolean },
) {
  let enumName = name.replace(JS_ENUM_INVALID_CHARS_RE, (c) => {
    const last = c[c.length - 1];
    return JS_PROPERTY_INDEX_INVALID_CHARS_RE.test(last)
      ? ""
      : last.toUpperCase();
  });
  if (Number(name[0]) >= 0) {
    enumName = `Value${name}`;
  }
  enumName = `${enumName[0].toUpperCase()}${enumName.substring(1)}`;
  return ts.factory.createEnumDeclaration(
    /* modifiers */ options
      ? tsModifiers({
          readonly: options.readonly ?? false,
          export: options.export ?? false,
        })
      : undefined,
    /* name      */ enumName,
    /* members   */ members.map((value, i) =>
      tsEnumMember(value, metadata?.[i]),
    ),
  );
}

/** Sanitize TS enum member expression */
export function tsEnumMember(
  value: string | number,
  metadata: { name?: string; description?: string } = {},
) {
  let name = metadata.name ?? String(value);
  if (!JS_PROPERTY_INDEX_RE.test(name)) {
    if (Number(name[0]) >= 0) {
      name = `Value${name}`.replace(".", "_"); // don't forged decimals;
    }
    name = name.replace(JS_PROPERTY_INDEX_INVALID_CHARS_RE, "_");
  }

  let member;
  if (typeof value === "number") {
    member = ts.factory.createEnumMember(
      name,
      ts.factory.createNumericLiteral(value),
    );
  } else {
    member = ts.factory.createEnumMember(
      name,
      ts.factory.createStringLiteral(value),
    );
  }

  if (metadata.description == undefined) {
    return member;
  }

  return ts.addSyntheticLeadingComment(
    member,
    ts.SyntaxKind.SingleLineCommentTrivia,
    " ".concat(metadata.description.trim()),
    true,
  );
}

/** Create an intersection type */
export function tsIntersection(types: ts.TypeNode[]): ts.TypeNode {
  if (types.length === 0) {
    return NEVER;
  }
  if (types.length === 1) {
    return types[0];
  }
  return ts.factory.createIntersectionTypeNode(tsDedupe(types));
}

/** Is this a primitive type (string, number, boolean, null, undefined)? */
export function tsIsPrimitive(type: ts.TypeNode): boolean {
  if (!type) {
    return true;
  }
  return (
    ts.SyntaxKind[type.kind] === "BooleanKeyword" ||
    ts.SyntaxKind[type.kind] === "NeverKeyword" ||
    ts.SyntaxKind[type.kind] === "NullKeyword" ||
    ts.SyntaxKind[type.kind] === "NumberKeyword" ||
    ts.SyntaxKind[type.kind] === "StringKeyword" ||
    ts.SyntaxKind[type.kind] === "UndefinedKeyword" ||
    ("literal" in type && tsIsPrimitive(type.literal as TypeLiteralNode))
  );
}

/** Create a literal type */
export function tsLiteral(value: unknown): ts.TypeNode {
  if (typeof value === "string") {
    return ts.factory.createLiteralTypeNode(
      ts.factory.createStringLiteral(value),
    );
  }
  if (typeof value === "number") {
    return ts.factory.createLiteralTypeNode(
      ts.factory.createNumericLiteral(value),
    );
  }
  if (typeof value === "boolean") {
    return value === true ? TRUE : FALSE;
  }
  if (value === null) {
    return NULL;
  }
  if (Array.isArray(value)) {
    if (value.length === 0) {
      return ts.factory.createArrayTypeNode(NEVER);
    }
    return ts.factory.createTupleTypeNode(
      value.map((v: unknown) => tsLiteral(v)),
    );
  }
  if (typeof value === "object") {
    const keys: ts.TypeElement[] = [];
    for (const [k, v] of Object.entries(value)) {
      keys.push(
        ts.factory.createPropertySignature(
          /* modifiers     */ undefined,
          /* name          */ tsPropertyIndex(k),
          /* questionToken */ undefined,
          /* type          */ tsLiteral(v),
        ),
      );
    }
    return keys.length
      ? ts.factory.createTypeLiteralNode(keys)
      : tsRecord(STRING, NEVER);
  }
  return UNKNOWN;
}

/** Modifiers (readonly) */
export function tsModifiers(modifiers: {
  readonly?: boolean;
  export?: boolean;
}): ts.Modifier[] {
  const typeMods: ts.Modifier[] = [];
  if (modifiers.export) {
    typeMods.push(ts.factory.createModifier(ts.SyntaxKind.ExportKeyword));
  }
  if (modifiers.readonly) {
    typeMods.push(ts.factory.createModifier(ts.SyntaxKind.ReadonlyKeyword));
  }
  return typeMods;
}

/** Create a T | null union */
export function tsNullable(types: ts.TypeNode[]): ts.TypeNode {
  return ts.factory.createUnionTypeNode([...types, NULL]);
}

/** Create a TS Omit<X, Y> type */
export function tsOmit(type: ts.TypeNode, keys: string[]): ts.TypeNode {
  return ts.factory.createTypeReferenceNode(
    ts.factory.createIdentifier("Omit"),
    [type, ts.factory.createUnionTypeNode(keys.map((k) => tsLiteral(k)))],
  );
}

/** Create a TS Record<X, Y> type */
export function tsRecord(key: ts.TypeNode, value: ts.TypeNode) {
  return ts.factory.createTypeReferenceNode(
    ts.factory.createIdentifier("Record"),
    [key, value],
  );
}

/** Create a valid property index */
export function tsPropertyIndex(index: string | number) {
  if (
    (typeof index === "number" && !(index < 0)) ||
    (typeof index === "string" &&
      String(Number(index)) === index &&
      index[0] !== "-")
  ) {
    return ts.factory.createNumericLiteral(index);
  }
  return typeof index === "string" && JS_PROPERTY_INDEX_RE.test(index)
    ? ts.factory.createIdentifier(index)
    : ts.factory.createStringLiteral(String(index));
}

/** Create a union type */
export function tsUnion(types: ts.TypeNode[]): ts.TypeNode {
  if (types.length === 0) {
    return NEVER;
  }
  if (types.length === 1) {
    return types[0];
  }
  return ts.factory.createUnionTypeNode(tsDedupe(types));
}

/** Create a WithRequired<X, Y> type */
export function tsWithRequired(
  type: ts.TypeNode,
  keys: string[],
  injectFooter: ts.Node[], // needed to inject type helper if used
): ts.TypeNode {
  if (keys.length === 0) {
    return type;
  }

  // inject helper, if needed
  if (
    !injectFooter.some(
      (node) =>
        ts.isTypeAliasDeclaration(node) &&
        node?.name?.escapedText === "WithRequired",
    )
  ) {
    const helper = stringToAST(
      `type WithRequired<T, K extends keyof T> = T & { [P in K]-?: T[P] };`,
    )[0] as any; // eslint-disable-line @typescript-eslint/no-explicit-any
    injectFooter.push(helper);
  }

  return ts.factory.createTypeReferenceNode(
    ts.factory.createIdentifier("WithRequired"),
    [type, tsUnion(keys.map((k) => tsLiteral(k)))],
  );
}
