import type { HTMLInputTypeAttribute, InputHTMLAttributes } from "react";
import type { EntityManager } from "../entities";
import { InvalidFieldConfigException, TransformPersistFailedException } from "../errors";
import type { FieldSpec } from "data/connection/Connection";
import type { TFieldTSType } from "data/entities/EntityTypescript";
import { s, parse, InvalidSchemaError, snakeToPascalWithSpaces } from "bknd/utils";

// @todo: contexts need to be reworked
// e.g. "table" is irrelevant, because if read is not given, it fails

export const ActionContext = ["create", "read", "update", "delete"] as const;
export type TActionContext = (typeof ActionContext)[number];

export const RenderContext = ["form", "table", "read", "submit"] as const;
export type TRenderContext = (typeof RenderContext)[number];

const TmpContext = ["create", "read", "update", "delete", "form", "table", "submit"] as const;
export type TmpActionAndRenderContext = (typeof TmpContext)[number];

const DEFAULT_REQUIRED = false;
const DEFAULT_FILLABLE = true;
const DEFAULT_HIDDEN = false;

// @todo: add refine functions (e.g. if required, but not fillable, needs default value)
export const baseFieldConfigSchema = s
   .strictObject({
      label: s.string(),
      description: s.string(),
      required: s.boolean({ default: false }),
      fillable: s.anyOf([
         s.boolean({ title: "Boolean" }),
         s.array(s.string({ enum: ActionContext }), { title: "Context", uniqueItems: true }),
      ]),
      hidden: s.anyOf([
         s.boolean({ title: "Boolean" }),
         // @todo: tmp workaround
         s.array(s.string({ enum: TmpContext }), { title: "Context", uniqueItems: true }),
      ]),
      // if field is virtual, it will not call transformPersist & transformRetrieve
      virtual: s.boolean(),
      default_value: s.any(),
   })
   .partial();
export type BaseFieldConfig = s.Static<typeof baseFieldConfigSchema>;

export abstract class Field<
   Config extends BaseFieldConfig = BaseFieldConfig,
   Type = any,
   Required extends true | false = false,
> {
   _required!: Required;
   _type!: Type;

   /**
    * Property name that gets persisted on database
    */
   readonly name: string;
   readonly type: string = "field";
   readonly config: Config;

   constructor(name: string, config?: Partial<Config>) {
      this.name = name;
      this._type;
      this._required;

      try {
         this.config = parse(this.getSchema(), config || {}) as Config;
      } catch (e) {
         if (e instanceof InvalidSchemaError) {
            throw new InvalidFieldConfigException(this, config, e);
         }

         throw e;
      }
   }

   getType() {
      return this.type;
   }

   protected abstract getSchema(): s.ObjectSchema;

   /**
    * Used in SchemaManager.ts
    * @param em
    */
   schema(): FieldSpec | undefined {
      return Object.freeze({
         name: this.name,
         type: "text",
         nullable: true,
         // see field-test-suite.ts:41
         dflt: undefined,
         //dflt: this.getDefault(),
      });
   }

   hasDefault() {
      return this.config.default_value !== undefined;
   }

   getDefault() {
      return this.config?.default_value;
   }

   isFillable(context?: TActionContext): boolean {
      if (Array.isArray(this.config.fillable)) {
         return context ? this.config.fillable.includes(context) : DEFAULT_FILLABLE;
      }
      return this.config.fillable ?? DEFAULT_FILLABLE;
   }

   isHidden(context?: TmpActionAndRenderContext): boolean {
      if (Array.isArray(this.config.hidden)) {
         return context ? this.config.hidden.includes(context as any) : DEFAULT_HIDDEN;
      }
      return this.config.hidden ?? DEFAULT_HIDDEN;
   }

   isRequired(): boolean {
      return this.config?.required ?? false;
   }

   /**
    * Virtual fields are not persisted or retrieved from database
    * Used for MediaField, to add specifics about uploads, etc.
    */
   isVirtual(): boolean {
      return this.config.virtual ?? false;
   }

   getLabel(options?: { fallback?: boolean }): string | undefined {
      return this.config.label
         ? this.config.label
         : options?.fallback !== false
           ? snakeToPascalWithSpaces(this.name)
           : undefined;
   }

   getDescription(): string | undefined {
      return this.config.description;
   }

   /**
    * [GET] DB -> field.transformRetrieve -> [sent]
    *   table: form.getValue("table")
    *   form: form.getValue("form") -> modified -> form.getValue("submit") -> [sent]
    *
    * [PATCH] body parse json -> field.transformPersist -> [stored]
    *
    * @param value
    * @param context
    */
   getValue(value: any, context?: TRenderContext) {
      return value;
   }

   getHtmlConfig(): { element: HTMLInputTypeAttribute | string; props?: InputHTMLAttributes<any> } {
      return {
         element: "input",
         props: { type: "text" },
      };
   }

   // @todo: add field level validation
   isValid(value: any, context: TActionContext): boolean {
      if (typeof value !== "undefined") {
         return this.isFillable(context);
      } else if (context === "create") {
         return !this.isRequired();
      }
      return true;
   }

   /**
    * Transform value after retrieving from database
    * @param value
    */
   transformRetrieve(value: any): any {
      return value;
   }

   /**
    * Transform value before persisting to database
    * @param value
    * @param em EntityManager (optional, for relation fields)
    */
   async transformPersist(
      value: unknown,
      em: EntityManager<any>,
      context: TActionContext,
   ): Promise<any> {
      if (this.nullish(value)) {
         if (this.isRequired() && !this.hasDefault()) {
            throw TransformPersistFailedException.required(this.name);
         }
         return this.getDefault();
      }

      return value;
   }

   protected toSchemaWrapIfRequired<Schema extends s.Schema>(schema: Schema): Schema {
      return this.isRequired() ? schema : (schema.optional() as any);
   }

   protected nullish(value: any) {
      return value === null || value === undefined;
   }

   toJsonSchema(): s.Schema {
      return this.toSchemaWrapIfRequired(s.any());
   }

   toType(): TFieldTSType {
      return {
         required: this.isRequired(),
         comment: this.getDescription(),
         type: "any",
      };
   }

   toJSON() {
      return {
         // @todo: current workaround because of fixed string type
         type: this.type as any,
         config: this.config,
      };
   }
}
