import { config } from "core/config";
import { snakeToPascalWithSpaces, transformObject, $console, s, parse } from "bknd/utils";
import {
   type Field,
   PrimaryField,
   primaryFieldTypes,
   type TActionContext,
   type TRenderContext,
} from "../fields";

// @todo: entity must be migrated to typebox
export const entityConfigSchema = s
   .strictObject(
      {
         name: s.string(),
         name_singular: s.string(),
         description: s.string(),
         sort_field: s.string({ default: config.data.default_primary_field }),
         sort_dir: s.string({ enum: ["asc", "desc"], default: "asc" }),
         primary_format: s.string({ enum: primaryFieldTypes }),
      },
      { default: {} },
   )
   .partial();

export type EntityConfig = s.Static<typeof entityConfigSchema>;

export type EntityData = Record<string, any>;
export type EntityJSON = ReturnType<Entity["toJSON"]>;

/**
 * regular: normal defined entity
 * system: generated by the system, e.g. "users" from auth
 * generated: result of a relation, e.g. many-to-many relation's connection entity
 */
export const entityTypes = ["regular", "system", "generated"] as const;
export type TEntityType = (typeof entityTypes)[number];

const ENTITY_SYMBOL = Symbol.for("bknd:entity");

/**
 * @todo: add check for adding fields (primary and relation not allowed)
 * @todo: add option to disallow api deletes (or api actions in general)
 */
export class Entity<
   EntityName extends string = string,
   Fields extends Record<string, Field<any, any, any>> = Record<string, Field<any, any, any>>,
> {
   readonly #_name!: EntityName;
   readonly #_fields!: Fields; // only for types

   readonly name: string;
   readonly fields: Field[];
   readonly config: EntityConfig;
   protected data: EntityData[] | undefined;
   readonly type: TEntityType = "regular";

   constructor(name: string, fields?: Field[], config?: EntityConfig, type?: TEntityType) {
      if (typeof name !== "string" || name.length === 0) {
         throw new Error("Entity name must be a non-empty string");
      }

      this.name = name;
      this.config = parse(entityConfigSchema, config || {}) as EntityConfig;

      // add id field if not given
      // @todo: add test
      const primary_count = fields?.filter((field) => field instanceof PrimaryField).length ?? 0;
      if (primary_count > 1) {
         throw new Error(`Entity "${name}" has more than one primary field`);
      }
      this.fields =
         primary_count === 1
            ? []
            : [
                 new PrimaryField(undefined, {
                    format: this.config.primary_format,
                 }),
              ];

      if (fields) {
         fields.forEach((field) => this.addField(field));
      }

      if (type) this.type = type;
      this[ENTITY_SYMBOL] = true;
   }

   // this is currently required as there could be multiple variants
   // we need to migrate to a mono repo
   static isEntity(e: unknown): e is Entity {
      if (!e) return false;
      return e[ENTITY_SYMBOL] === true;
   }

   static create(args: {
      name: string;
      fields?: Field[];
      config?: EntityConfig;
      type?: TEntityType;
   }) {
      return new Entity(args.name, args.fields, args.config, args.type);
   }

   // @todo: add test
   getType(): TEntityType {
      return this.type;
   }

   getSelect(alias?: string, context?: TActionContext | TRenderContext): string[] {
      return this.getFields()
         .filter((field) => !field.isHidden(context ?? "read"))
         .map((field) => (alias ? `${alias}.${field.name} as ${field.name}` : field.name));
   }

   getDefaultSort() {
      return {
         by: this.config.sort_field ?? "id",
         dir: this.config.sort_dir ?? "asc",
      };
   }

   getAliasedSelectFrom(
      select: string[],
      _alias?: string,
      context?: TActionContext | TRenderContext,
   ): string[] {
      const alias = _alias ?? this.name;
      return this.getFields()
         .filter(
            (field) =>
               !field.isVirtual() &&
               !field.isHidden(context ?? "read") &&
               select.includes(field.name),
         )
         .map((field) => (alias ? `${alias}.${field.name} as ${field.name}` : field.name));
   }

   getFillableFields(context?: TActionContext, include_virtual?: boolean): Field[] {
      return this.getFields(include_virtual).filter((field) => field.isFillable(context));
   }

   getRequiredFields(): Field[] {
      return this.getFields().filter((field) => field.isRequired());
   }

   getDefaultObject(): EntityData {
      return this.getFields().reduce((acc, field) => {
         if (field.hasDefault()) {
            acc[field.name] = field.getDefault();
         }
         return acc;
      }, {} as EntityData);
   }

   getField(name: string): Field | undefined {
      return this.fields.find((field) => field.name === name);
   }

   __replaceField(name: string, field: Field) {
      const index = this.fields.findIndex((f) => f.name === name);
      if (index === -1) {
         throw new Error(`Field "${name}" not found on entity "${this.name}"`);
      }

      this.fields[index] = field;
   }

   getPrimaryField(): PrimaryField {
      return this.fields[0] as PrimaryField;
   }

   id(): PrimaryField {
      return this.getPrimaryField();
   }

   get label(): string {
      return this.config.name ?? snakeToPascalWithSpaces(this.name);
   }

   field(name: string): Field | undefined {
      return this.getField(name);
   }

   hasField(name: string): boolean;
   hasField(field: Field): boolean;
   hasField(nameOrField: string | Field): boolean {
      const name = typeof nameOrField === "string" ? nameOrField : nameOrField.name;
      return this.fields.findIndex((field) => field.name === name) !== -1;
   }

   getFields(include_virtual: boolean = false): Field[] {
      if (include_virtual) return this.fields;
      return this.fields.filter((f) => !f.isVirtual());
   }

   addField(field: Field) {
      const existing = this.getField(field.name);
      // make unique name check
      if (existing) {
         // @todo: for now adding a graceful method
         if (JSON.stringify(existing) === JSON.stringify(field)) {
            $console.warn(
               `Field "${field.name}" already exists on entity "${this.name}", but it's the same, so skipping.`,
            );
            return;
         }

         throw new Error(`Field "${field.name}" already exists on entity "${this.name}"`);
      }

      this.fields.push(field);
   }

   __setData(data: EntityData[]) {
      this.data = data;
   }

   // @todo: add tests
   isValidData(
      data: EntityData,
      context: TActionContext,
      options?: {
         explain?: boolean;
         ignoreUnknown?: boolean;
      },
   ): boolean {
      if (typeof data !== "object") {
         if (options?.explain) {
            throw new Error(`Entity "${this.name}" data must be an object`);
         }
      }

      const fields = this.getFillableFields(context, false);

      if (options?.ignoreUnknown !== true) {
         const field_names = fields.map((f) => f.name);
         const given_keys = Object.keys(data);
         const unknown_keys = given_keys.filter((key) => !field_names.includes(key));

         if (unknown_keys.length > 0) {
            if (options?.explain) {
               throw new Error(
                  `Entity "${this.name}" data must only contain known keys, unknown: "${unknown_keys}"`,
               );
            }
         }
      }

      for (const field of fields) {
         if (!field.isValid(data?.[field.name], context)) {
            $console.warn(
               "invalid data given for",
               this.name,
               context,
               field.name,
               data[field.name],
            );
            if (options?.explain) {
               throw new Error(`Field "${field.name}" has invalid data: "${data[field.name]}"`);
            }

            return false;
         }
      }

      return true;
   }

   toSchema(options?: { clean: boolean; context?: "create" | "update" }): object {
      let fields: Field[];
      switch (options?.context) {
         case "create":
         case "update":
            fields = this.getFillableFields(options.context);
            break;
         default:
            fields = this.getFields(true);
      }

      const _fields = Object.fromEntries(fields.map((field) => [field.name, field]));
      const schema = {
         type: "object",
         additionalProperties: false,
         properties: transformObject(_fields, (field) => {
            const fillable = field.isFillable(options?.context);
            return {
               title: field.config.label,
               $comment: field.config.description,
               $field: field.type,
               readOnly: !fillable ? true : undefined,
               ...field.toJsonSchema(),
            };
         }),
      };

      return options?.clean ? JSON.parse(JSON.stringify(schema)) : schema;
   }

   toTypes() {
      return {
         name: this.name,
         type: this.type,
         comment: this.config.description,
         fields: Object.fromEntries(this.getFields().map((field) => [field.name, field.toType()])),
      };
   }

   toJSON() {
      return {
         type: this.type,
         fields: Object.fromEntries(this.fields.map((field) => [field.name, field.toJSON()])),
         config: this.config,
      };
   }
}
