/* eslint-disable @typescript-eslint/no-non-null-asserted-optional-chain */
/*
 * Copyright (C) 2021 StApps
 * This program is free software: you can redistribute it and/or modify it
 * under the terms of the GNU General Public License as published by the Free
 * Software Foundation, version 3.
 *
 * This program is distributed in the hope that it will be useful, but WITHOUT
 * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
 * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
 * more details.
 *
 * You should have received a copy of the GNU General Public License along with
 * this program. If not, see <https://www.gnu.org/licenses/>.
 */
import ts from 'typescript';
import {cleanupEmpty, expandPathToFilesSync, mapNotNil, rejectNil} from './util.js';
import {
  extractComment,
  filterChildrenTo,
  filterNodeTo,
  getModifiers,
  isArrayLikeType,
  isClassLikeNode,
  isEnumLikeNode,
  isProperty,
  resolvePropertyName,
  resolveTypeName,
} from './ast-internal-util.js';
import {isEnumLiteralType, isTypeVariable} from './ast-util.js';
import {LightweightAliasDefinition} from './types/lightweight-alias-definition.js';
import {LightweightClassDefinition} from './types/lightweight-class-definition.js';
import {LightweightDefinition} from './types/lightweight-definition.js';
import {LightweightDefinitionKind} from './types/lightweight-definition-kind.js';
import {LightweightProject} from './types/lightweight-project.js';
import {LightweightType} from './types/lightweight-type.js';
import path from 'path';
import {LightweightProperty} from './types/lightweight-property.js';
import {mapValues, groupBy, keyBy} from '@openstapps/collection-utils';

/**
 * Convert a TypeScript project to a lightweight Type-AST representation of the project
 * @param sourcePath either a directory or a set of input files
 * @param includeComments if comments should be included (default true)
 */
export function lightweightProjectFromPath(
  sourcePath: string | string[],
  includeComments = true,
): LightweightProject {
  return new LightweightDefinitionBuilder(sourcePath, includeComments).convert();
}

/**
 * Convert a TypeScript project to a set of lightweight definition ASTs
 * @param sourcePath either a directory or a set of input files
 * @param includeComments if comments should be included (default true)
 */
export function lightweightDefinitionsFromPath(
  sourcePath: string | string[],
  includeComments = true,
): LightweightDefinition[] {
  return rejectNil(new LightweightDefinitionBuilder(sourcePath, includeComments).convertToList());
}

/**
 * Reads the reflection model and converts it into a flatter, easier to handle model
 */
class LightweightDefinitionBuilder {
  readonly program: ts.Program;

  readonly sourceFiles: readonly ts.SourceFile[];

  readonly typeChecker: ts.TypeChecker;

  constructor(sourcePath: string | string[], readonly includeComments: boolean) {
    const rootNames = Array.isArray(sourcePath)
      ? sourcePath
      : expandPathToFilesSync(path.resolve(sourcePath), it => it.endsWith('.ts'));

    this.program = ts.createProgram({
      rootNames: rootNames,
      options: {
        alwaysStrict: true,
        charset: 'utf8',
        declaration: true,
        esModuleInterop: true,
        experimentalDecorators: true,
        inlineSourceMap: true,
        module: ts.ModuleKind.NodeNext,
        strict: true,
        target: ts.ScriptTarget.ES2015,
      },
    });

    this.typeChecker = this.program.getTypeChecker();
    this.sourceFiles = mapNotNil(this.program.getRootFileNames(), it => this.program.getSourceFile(it));
  }

  private convertAliasLike(
    enumLike: ts.EnumDeclaration | ts.TypeAliasDeclaration,
  ): LightweightAliasDefinition {
    return cleanupEmpty({
      comment: this.includeComments ? extractComment(enumLike) : undefined,
      name: enumLike.name.getText() ?? 'ERROR',
      kind: LightweightDefinitionKind.ALIAS_LIKE,
      modifiers: getModifiers(enumLike.getText(), ts.isEnumDeclaration(enumLike) ? 'enum' : 'type'),
      type: ts.isEnumDeclaration(enumLike)
        ? enumLike.members.length > 0
          ? {
              flags: 1_048_576,
              specificationTypes: enumLike.members.map(it => this.lightweightTypeAtNode(it)),
            }
          : undefined
        : this.lightweightTypeFromType(this.typeChecker.getTypeFromTypeNode(enumLike.type), enumLike.type),
    });
  }

  private convertClassLike(
    classLike: ts.ClassDeclaration | ts.InterfaceDeclaration,
  ): LightweightClassDefinition {
    const heritages = mapValues(
      groupBy([...(classLike.heritageClauses || [])], it => it.token.toString()),
      heritages => heritages.flatMap(it => it.types),
    );

    return cleanupEmpty({
      comment: this.includeComments ? extractComment(classLike) : undefined,
      name: classLike.name?.escapedText ?? 'ERROR',
      kind: LightweightDefinitionKind.CLASS_LIKE,
      modifiers: getModifiers(classLike.getText(), ts.isClassDeclaration(classLike) ? 'class' : 'interface'),
      extendedDefinitions: heritages[ts.SyntaxKind.ExtendsKeyword]?.map(it => this.lightweightTypeAtNode(it)),
      implementedDefinitions: heritages[ts.SyntaxKind.ImplementsKeyword]?.map(it =>
        this.lightweightTypeAtNode(it),
      ),
      indexSignatures: keyBy(
        filterNodeTo(
          classLike.members as ts.NodeArray<ts.ClassElement | ts.TypeElement>,
          ts.isIndexSignatureDeclaration,
        ).map(indexSignature =>
          cleanupEmpty({
            name:
              this.typeChecker.getSignatureFromDeclaration(indexSignature)?.parameters?.[0]?.escapedName ??
              'UNRESOLVED_INDEX_SIGNATURE',
            type: this.lightweightTypeFromType(
              this.typeChecker.getTypeFromTypeNode(indexSignature.type),
              indexSignature.type,
            ),
            indexSignatureType: this.lightweightTypeFromType(
              this.typeChecker.getTypeFromTypeNode(indexSignature.parameters[0].type!),
              indexSignature.parameters[0].type!,
            ),
          }),
        ),
        it => it.name,
      ),
      typeParameters: classLike.typeParameters?.map(it => it.name.getText()),
      properties: this.collectProperties(classLike.members),
    });
  }

  collectProperties(
    members: ts.NodeArray<ts.ClassElement | ts.TypeElement>,
  ): Record<string, LightweightProperty> | undefined {
    return members
      ? keyBy(
          filterNodeTo(members as ts.NodeArray<ts.ClassElement | ts.TypeElement>, isProperty).map(property =>
            cleanupEmpty({
              comment: this.includeComments ? extractComment(property) : undefined,
              name: resolvePropertyName(property.name) ?? property.getText(),
              type: this.lightweightTypeAtNode(property),
              properties: this.collectProperties((property.type as ts.TypeLiteralNode)?.members),
              optional: ts.isPropertyDeclaration(property)
                ? property.questionToken === undefined
                  ? undefined
                  : true
                : undefined,
            }),
          ),
          it => it.name,
        )
      : undefined;
  }

  private lightweightTypeAtNode(node: ts.Node): LightweightType {
    const type = this.typeChecker.getTypeAtLocation(node);

    return this.lightweightTypeFromType(type, this.typeChecker.typeToTypeNode(type, node, undefined));
  }

  private lightweightTypeFromType(type: ts.Type, typeNode?: ts.TypeNode): LightweightType {
    if (typeNode?.kind === ts.SyntaxKind.ConditionalType) {
      return {value: 'UNSUPPORTED_CONDITIONAL_TYPE', flags: ts.TypeFlags.Unknown};
    }
    if (isArrayLikeType(typeNode)) {
      const elementType = ts.isArrayTypeNode(typeNode) ? typeNode.elementType : typeNode.typeArguments?.[0]!;
      const out = this.lightweightTypeFromType(
        this.typeChecker.getTypeFromTypeNode(elementType),
        elementType,
      );
      out.isArray = true;

      return out;
    }
    const isReference =
      typeNode !== undefined && ts.isTypeReferenceNode(typeNode) && !isEnumLiteralType(type);
    const isTypeLiteral = typeNode !== undefined && ts.isTypeLiteralNode(typeNode);
    // @ts-expect-error intrinsic name & value exist
    const intrinsicName = (type.intrinsicName ?? type.value) as string | undefined;

    return cleanupEmpty({
      value: intrinsicName,
      referenceName: isTypeLiteral
        ? undefined
        : resolveTypeName(typeNode) ?? (type.symbol?.escapedName as string | undefined),
      flags: type.flags,
      genericsTypes: isTypeVariable(type)
        ? undefined
        : this.typeChecker
            .getApparentType(type)
            // @ts-expect-error resolvedTypeArguments exits
            ?.resolvedTypeArguments?.filter(it => !it.isThisType)
            ?.map((it: ts.Type) => this.lightweightTypeFromType(it)),
      specificationTypes:
        type.isUnionOrIntersection() && !isReference
          ? type.types.map(it =>
              this.lightweightTypeFromType(it, this.typeChecker.typeToTypeNode(it, undefined, undefined)),
            )
          : undefined,
    });
  }

  /**
   * Start the conversion process
   */
  convert(): LightweightProject {
    return mapValues(
      keyBy([...this.sourceFiles], it => it.fileName),
      file =>
        keyBy(
          [
            ...filterChildrenTo(file, isClassLikeNode).map(it => this.convertClassLike(it)),
            ...filterChildrenTo(file, isEnumLikeNode).map(it => this.convertAliasLike(it)),
          ],
          it => it.name,
        ),
    );
  }

  /**
   * Same as conversion, but generates a simple list of all definitions.
   */
  convertToList(): LightweightDefinition[] {
    return Object.values(this.convert()).flatMap(it => it.values);
  }
}
