import * as plugins from './plugins.js';

import { SmartdataDb } from './classes.db.js';
import { SmartdataDbCursor } from './classes.cursor.js';
import { type IManager, SmartdataCollection } from './classes.collection.js';
import { SmartdataDbWatcher } from './classes.watcher.js';
import { SmartdataLuceneAdapter } from './classes.lucene.adapter.js';
/**
 * Search options for `.search()`:
 * - filter: additional MongoDB query to AND-merge
 * - validate: post-fetch validator, return true to keep a doc
 */
export interface SearchOptions<T> {
  /**
   * Additional MongoDB filter to AND‐merge into the query
   */
  filter?: Record<string, any>;
  /**
   * Post‐fetch validator; return true to keep each doc
   */
  validate?: (doc: T) => Promise<boolean> | boolean;
  /**
   * Optional MongoDB session for transactional operations
   */
  session?: plugins.mongodb.ClientSession;
}

export type TDocCreation = 'db' | 'new' | 'mixed';



export function globalSvDb() {
  return (target: SmartDataDbDoc<unknown, unknown>, key: string) => {
    console.log(`called svDb() on >${target.constructor.name}.${key}<`);
    if (!target.globalSaveableProperties) {
      target.globalSaveableProperties = [];
    }
    target.globalSaveableProperties.push(key);
  };
}

/**
 * Options for custom serialization/deserialization of a field.
 */
export interface SvDbOptions {
  /** Function to serialize the field value before saving to DB */
  serialize?: (value: any) => any;
  /** Function to deserialize the field value after reading from DB */
  deserialize?: (value: any) => any;
}

/**
 * saveable - saveable decorator to be used on class properties
 */
export function svDb(options?: SvDbOptions) {
  return (target: SmartDataDbDoc<unknown, unknown>, key: string) => {
    console.log(`called svDb() on >${target.constructor.name}.${key}<`);
    if (!target.saveableProperties) {
      target.saveableProperties = [];
    }
    target.saveableProperties.push(key);
    // attach custom serializer/deserializer options to the class constructor
    const ctor = target.constructor as any;
    if (!ctor._svDbOptions) {
      ctor._svDbOptions = {};
    }
    if (options) {
      ctor._svDbOptions[key] = options;
    }
  };
}

/**
 * searchable - marks a property as searchable with Lucene query syntax
 */
export function searchable() {
  return (target: SmartDataDbDoc<unknown, unknown>, key: string) => {
    // Attach to class constructor for direct access
    const ctor = target.constructor as any;
    if (!Array.isArray(ctor.searchableFields)) {
      ctor.searchableFields = [];
    }
    ctor.searchableFields.push(key);
  };
}

// Escape user input for safe use in MongoDB regular expressions
function escapeForRegex(input: string): string {
  return input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}

/**
 * unique index - decorator to mark a unique index
 */
export function unI() {
  return (target: SmartDataDbDoc<unknown, unknown>, key: string) => {
    console.log(`called unI on >>${target.constructor.name}.${key}<<`);

    // mark the index as unique
    if (!target.uniqueIndexes) {
      target.uniqueIndexes = [];
    }
    target.uniqueIndexes.push(key);

    // and also save it
    if (!target.saveableProperties) {
      target.saveableProperties = [];
    }
    target.saveableProperties.push(key);
  };
}

/**
 * Options for MongoDB indexes
 */
export interface IIndexOptions {
  background?: boolean;
  unique?: boolean;
  sparse?: boolean;
  expireAfterSeconds?: number;
  [key: string]: any;
}

/**
 * index - decorator to mark a field for regular indexing
 */
export function index(options?: IIndexOptions) {
  return (target: SmartDataDbDoc<unknown, unknown>, key: string) => {
    console.log(`called index() on >${target.constructor.name}.${key}<`);
    
    // Initialize regular indexes array if it doesn't exist
    if (!target.regularIndexes) {
      target.regularIndexes = [];
    }
    
    // Add this field to regularIndexes with its options
    target.regularIndexes.push({
      field: key,
      options: options || {}
    });
    
    // Also ensure it's marked as saveable
    if (!target.saveableProperties) {
      target.saveableProperties = [];
    }
    
    if (!target.saveableProperties.includes(key)) {
      target.saveableProperties.push(key);
    }
  };
}

export const convertFilterForMongoDb = (filterArg: { [key: string]: any }) => {
  // Special case: detect MongoDB operators and pass them through directly
  const topLevelOperators = ['$and', '$or', '$nor', '$not', '$text', '$where', '$regex'];
  for (const key of Object.keys(filterArg)) {
    if (topLevelOperators.includes(key)) {
      return filterArg; // Return the filter as-is for MongoDB operators
    }
  }

  // Original conversion logic for non-MongoDB query objects
  const convertedFilter: { [key: string]: any } = {};

  const convertFilterArgument = (keyPathArg2: string, filterArg2: any) => {
    if (Array.isArray(filterArg2)) {
      // Directly assign arrays (they might be using operators like $in or $all)
      convertFilterArgument(keyPathArg2, filterArg2[0]);
    } else if (typeof filterArg2 === 'object' && filterArg2 !== null) {
      for (const key of Object.keys(filterArg2)) {
        if (key.startsWith('$')) {
          convertedFilter[keyPathArg2] = filterArg2;
          return;
        } else if (key.includes('.')) {
          throw new Error('keys cannot contain dots');
        }
      }
      for (const key of Object.keys(filterArg2)) {
        convertFilterArgument(`${keyPathArg2}.${key}`, filterArg2[key]);
      }
    } else {
      convertedFilter[keyPathArg2] = filterArg2;
    }
  };

  for (const key of Object.keys(filterArg)) {
    convertFilterArgument(key, filterArg[key]);
  }
  return convertedFilter;
};

export class SmartDataDbDoc<T extends TImplements, TImplements, TManager extends IManager = any> {
  /**
   * the collection object an Doc belongs to
   */
  public static collection: SmartdataCollection<any>;
  public collection: SmartdataCollection<any>;
  public static defaultManager;
  public static manager;
  public manager: TManager;

  // STATIC
  public static createInstanceFromMongoDbNativeDoc<T>(
    this: plugins.tsclass.typeFest.Class<T>,
    mongoDbNativeDocArg: any,
  ): T {
    const newInstance = new this();
    (newInstance as any).creationStatus = 'db';
    for (const key of Object.keys(mongoDbNativeDocArg)) {
      const rawValue = mongoDbNativeDocArg[key];
      const optionsMap = (this as any)._svDbOptions || {};
      const opts = optionsMap[key];
      newInstance[key] = opts && typeof opts.deserialize === 'function'
        ? opts.deserialize(rawValue)
        : rawValue;
    }
    return newInstance;
  }

  /**
   * gets all instances as array
   * @param this
   * @param filterArg
   * @returns
   */
  public static async getInstances<T>(
    this: plugins.tsclass.typeFest.Class<T>,
    filterArg: plugins.tsclass.typeFest.PartialDeep<T>,
    opts?: { session?: plugins.mongodb.ClientSession }
  ): Promise<T[]> {
    // Pass session through to findAll for transactional queries
    const foundDocs = await (this as any).collection.findAll(
      convertFilterForMongoDb(filterArg),
      { session: opts?.session },
    );
    const returnArray = [];
    for (const foundDoc of foundDocs) {
      const newInstance: T = (this as any).createInstanceFromMongoDbNativeDoc(foundDoc);
      returnArray.push(newInstance);
    }
    return returnArray;
  }

  /**
   * gets the first matching instance
   * @param this
   * @param filterArg
   * @returns
   */
  public static async getInstance<T>(
    this: plugins.tsclass.typeFest.Class<T>,
    filterArg: plugins.tsclass.typeFest.PartialDeep<T>,
    opts?: { session?: plugins.mongodb.ClientSession }
  ): Promise<T> {
    // Retrieve one document, with optional session for transactions
    const foundDoc = await (this as any).collection.findOne(
      convertFilterForMongoDb(filterArg),
      { session: opts?.session },
    );
    if (foundDoc) {
      const newInstance: T = (this as any).createInstanceFromMongoDbNativeDoc(foundDoc);
      return newInstance;
    } else {
      return null;
    }
  }

  /**
   * get a unique id prefixed with the class name
   */
  public static async getNewId<T = any>(
    this: plugins.tsclass.typeFest.Class<T>,
    lengthArg: number = 20,
  ) {
    return `${(this as any).className}:${plugins.smartunique.shortId(lengthArg)}`;
  }

  /**
   * Get a cursor for streaming results, with optional session and native cursor modifiers.
   * @param filterArg Partial filter to apply
   * @param opts Optional session and modifier for the raw MongoDB cursor
   */
  public static async getCursor<T>(
    this: plugins.tsclass.typeFest.Class<T>,
    filterArg: plugins.tsclass.typeFest.PartialDeep<T>,
    opts?: {
      session?: plugins.mongodb.ClientSession;
      modifier?: (cursorArg: plugins.mongodb.FindCursor<plugins.mongodb.WithId<plugins.mongodb.BSON.Document>>) => plugins.mongodb.FindCursor<plugins.mongodb.WithId<plugins.mongodb.BSON.Document>>;
    }
  ): Promise<SmartdataDbCursor<T>> {
    const collection: SmartdataCollection<T> = (this as any).collection;
    const { session, modifier } = opts || {};
    await collection.init();
    let rawCursor: plugins.mongodb.FindCursor<any> =
      collection.mongoDbCollection.find(convertFilterForMongoDb(filterArg), { session });
    if (modifier) {
      rawCursor = modifier(rawCursor);
    }
    return new SmartdataDbCursor<T>(rawCursor, this as any as typeof SmartDataDbDoc);
  }

  /**
   * watch the collection
   * @param this
   * @param filterArg
   * @param forEachFunction
   */
  public static async watch<T>(
    this: plugins.tsclass.typeFest.Class<T>,
    filterArg: plugins.tsclass.typeFest.PartialDeep<T>,
  ) {
    const collection: SmartdataCollection<T> = (this as any).collection;
    const watcher: SmartdataDbWatcher<T> = await collection.watch(
      convertFilterForMongoDb(filterArg),
      this as any,
    );
    return watcher;
  }

  /**
   * run a function for all instances
   * @returns
   */
  public static async forEach<T>(
    this: plugins.tsclass.typeFest.Class<T>,
    filterArg: plugins.tsclass.typeFest.PartialDeep<T>,
    forEachFunction: (itemArg: T) => Promise<any>,
  ) {
    const cursor: SmartdataDbCursor<T> = await (this as any).getCursor(filterArg);
    await cursor.forEach(forEachFunction);
  }

  /**
   * returns a count of the documents in the collection
   */
  public static async getCount<T>(
    this: plugins.tsclass.typeFest.Class<T>,
    filterArg: plugins.tsclass.typeFest.PartialDeep<T> = {} as any,
  ) {
    const collection: SmartdataCollection<T> = (this as any).collection;
    return await collection.getCount(filterArg);
  }

  /**
   * Create a MongoDB filter from a Lucene query string
   * @param luceneQuery Lucene query string
   * @returns MongoDB query object
   */
  public static createSearchFilter<T>(
    this: plugins.tsclass.typeFest.Class<T>,
    luceneQuery: string,
  ): any {
    const searchableFields = (this as any).getSearchableFields();
    if (searchableFields.length === 0) {
      throw new Error(`No searchable fields defined for class ${this.name}`);
    }
    const adapter = new SmartdataLuceneAdapter(searchableFields);
    return adapter.convert(luceneQuery);
  }
  /**
   * List all searchable fields defined on this class
   */
  public static getSearchableFields(): string[] {
    const ctor = this as any;
    return Array.isArray(ctor.searchableFields) ? ctor.searchableFields : [];
  }
  /**
   * Execute a query with optional hard filter and post-fetch validation
   */
  private static async execQuery<T>(
    this: plugins.tsclass.typeFest.Class<T>,
    baseFilter: Record<string, any>,
    opts?: SearchOptions<T>
  ): Promise<T[]> {
    let mongoFilter = baseFilter || {};
    if (opts?.filter) {
      mongoFilter = { $and: [mongoFilter, opts.filter] };
    }
    // Fetch with optional session for transactions
    // Fetch within optional session
    let docs: T[] = await (this as any).getInstances(mongoFilter, { session: opts?.session });
    if (opts?.validate) {
      const out: T[] = [];
      for (const d of docs) {
        if (await opts.validate(d)) out.push(d);
      }
      docs = out;
    }
    return docs;
  }

  /**
   * Search documents by text or field:value syntax, with safe regex fallback
   * Supports additional filtering and post-fetch validation via opts
   * @param query A search term or field:value expression
   * @param opts Optional filter and validate hooks
   * @returns Array of matching documents
   */
  public static async search<T>(
    this: plugins.tsclass.typeFest.Class<T>,
    query: string,
    opts?: SearchOptions<T>,
  ): Promise<T[]> {
    const searchableFields = (this as any).getSearchableFields();
    if (searchableFields.length === 0) {
      throw new Error(`No searchable fields defined for class ${this.name}`);
    }
    // empty query -> return all
    const q = query.trim();
    if (!q) {
      // empty query: fetch all, apply opts
      return await (this as any).execQuery({}, opts);
    }
    // simple exact field:value (no spaces, no wildcards, no quotes)
    // simple exact field:value (no spaces, wildcards, quotes)
    const simpleExact = q.match(/^(\w+):([^"'\*\?\s]+)$/);
    if (simpleExact) {
      const field = simpleExact[1];
      const value = simpleExact[2];
      if (!searchableFields.includes(field)) {
        throw new Error(`Field '${field}' is not searchable for class ${this.name}`);
      }
      // simple field:value search
      return await (this as any).execQuery({ [field]: value }, opts);
    }
    // quoted phrase across all searchable fields: exact match of phrase
    const quoted = q.match(/^"(.+)"$|^'(.+)'$/);
    if (quoted) {
      const phrase = quoted[1] || quoted[2] || '';
      const parts = phrase.split(/\s+/).map((t) => escapeForRegex(t));
      const pattern = parts.join('\\s+');
      const orConds = searchableFields.map((f) => ({ [f]: { $regex: pattern, $options: 'i' } }));
      return await (this as any).execQuery({ $or: orConds }, opts);
    }
    // wildcard field:value (supports * and ?) -> direct regex on that field
    const wildcardField = q.match(/^(\w+):(.+[*?].*)$/);
    if (wildcardField) {
      const field = wildcardField[1];
      // Support quoted wildcard patterns: strip surrounding quotes
      let pattern = wildcardField[2];
      if ((pattern.startsWith('"') && pattern.endsWith('"')) ||
          (pattern.startsWith("'") && pattern.endsWith("'"))) {
        pattern = pattern.slice(1, -1);
      }
      if (!searchableFields.includes(field)) {
        throw new Error(`Field '${field}' is not searchable for class ${this.name}`);
      }
      // escape regex special chars except * and ?, then convert wildcards
      const escaped = pattern.replace(/([.+^${}()|[\\]\\])/g, '\\$1');
      const regexPattern = escaped.replace(/\*/g, '.*').replace(/\?/g, '.');
      return await (this as any).execQuery({ [field]: { $regex: regexPattern, $options: 'i' } }, opts);
    }
    // wildcard plain term across all fields (supports * and ?)
    if (!q.includes(':') && (q.includes('*') || q.includes('?'))) {
      // build wildcard regex pattern: escape all except * and ? then convert
      const escaped = q.replace(/([.+^${}()|[\\]\\])/g, '\\$1');
      const pattern = escaped.replace(/\*/g, '.*').replace(/\?/g, '.');
      const orConds = searchableFields.map((f) => ({ [f]: { $regex: pattern, $options: 'i' } }));
      return await (this as any).execQuery({ $or: orConds }, opts);
    }
    // implicit AND for multiple tokens: free terms, quoted phrases, and field:values
    {
      // Split query into tokens, preserving quoted substrings
      const rawTokens = q.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) || [];
      // Only apply when more than one token and no boolean operators or grouping
      if (
        rawTokens.length > 1 &&
        !/(\bAND\b|\bOR\b|\bNOT\b|\(|\))/i.test(q) &&
        !/\[|\]/.test(q)
      ) {
        const andConds: any[] = [];
        for (let token of rawTokens) {
          // field:value token
          const fv = token.match(/^(\w+):(.+)$/);
          if (fv) {
            const field = fv[1];
            let value = fv[2];
            if (!searchableFields.includes(field)) {
              throw new Error(`Field '${field}' is not searchable for class ${this.name}`);
            }
            // Strip surrounding quotes if present
            if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
              value = value.slice(1, -1);
            }
            // Wildcard search?
            if (value.includes('*') || value.includes('?')) {
              const escaped = value.replace(/([.+^${}()|[\\]\\])/g, '\\$1');
              const pattern = escaped.replace(/\*/g, '.*').replace(/\?/g, '.');
              andConds.push({ [field]: { $regex: pattern, $options: 'i' } });
            } else {
              andConds.push({ [field]: value });
            }
          } else if ((token.startsWith('"') && token.endsWith('"')) || (token.startsWith("'") && token.endsWith("'"))) {
            // Quoted free phrase across all fields
            const phrase = token.slice(1, -1);
            const parts = phrase.split(/\s+/).map((t) => escapeForRegex(t));
            const pattern = parts.join('\\s+');
            andConds.push({ $or: searchableFields.map((f) => ({ [f]: { $regex: pattern, $options: 'i' } })) });
          } else {
            // Free term across all fields
            const esc = escapeForRegex(token);
            andConds.push({ $or: searchableFields.map((f) => ({ [f]: { $regex: esc, $options: 'i' } })) });
          }
        }
        return await (this as any).execQuery({ $and: andConds }, opts);
      }
    }
    // detect advanced Lucene syntax: field:value, wildcards, boolean, grouping
    const luceneSyntax = /(\w+:[^\s]+)|\*|\?|\bAND\b|\bOR\b|\bNOT\b|\(|\)/;
    if (luceneSyntax.test(q)) {
      const filter = (this as any).createSearchFilter(q);
      return await (this as any).execQuery(filter, opts);
    }
    // multi-term unquoted -> AND of regex across fields for each term
    const terms = q.split(/\s+/);
    if (terms.length > 1) {
      const andConds = terms.map((term) => {
        const esc = escapeForRegex(term);
        const ors = searchableFields.map((f) => ({ [f]: { $regex: esc, $options: 'i' } }));
        return { $or: ors };
      });
      return await (this as any).execQuery({ $and: andConds }, opts);
    }
    // single term -> regex across all searchable fields
    const esc = escapeForRegex(q);
    const orConds = searchableFields.map((f) => ({ [f]: { $regex: esc, $options: 'i' } }));
    return await (this as any).execQuery({ $or: orConds }, opts);
  }


  // INSTANCE

  // INSTANCE

  /**
   * how the Doc in memory was created, may prove useful later.
   */
  public creationStatus: TDocCreation = 'new';

  /**
   * updated from db in any case where doc comes from db
   */
  @globalSvDb()
  _createdAt: string = new Date().toISOString();

  /**
   * will be updated everytime the doc is saved
   */
  @globalSvDb()
  _updatedAt: string = new Date().toISOString();

  /**
   * an array of saveable properties of ALL doc
   */
  public globalSaveableProperties: string[];

  /**
   * unique indexes
   */
  public uniqueIndexes: string[];

  /**
   * regular indexes with their options
   */
  public regularIndexes: Array<{field: string, options: IIndexOptions}> = [];

  /**
   * an array of saveable properties of a specific doc
   */
  public saveableProperties: string[];

  /**
   * name
   */
  public name: string;

  /**
   * primary id in the database
   */
  public dbDocUniqueId: string;

  /**
   * class constructor
   */
  constructor() {}

  /**
   * saves this instance (optionally within a transaction)
   */
  public async save(opts?: { session?: plugins.mongodb.ClientSession }) {
    // allow hook before saving
    if (typeof (this as any).beforeSave === 'function') {
      await (this as any).beforeSave();
    }
    // tslint:disable-next-line: no-this-assignment
    const self: any = this;
    let dbResult: any;
    // update timestamp
    this._updatedAt = new Date().toISOString();
    // perform insert or update
    switch (this.creationStatus) {
      case 'db':
        dbResult = await this.collection.update(self, { session: opts?.session });
        break;
      case 'new':
        dbResult = await this.collection.insert(self, { session: opts?.session });
        this.creationStatus = 'db';
        break;
      default:
        console.error('neither new nor in db?');
    }
    // allow hook after saving
    if (typeof (this as any).afterSave === 'function') {
      await (this as any).afterSave();
    }
    return dbResult;
  }

  /**
   * deletes a document from the database (optionally within a transaction)
   */
  public async delete(opts?: { session?: plugins.mongodb.ClientSession }) {
    // allow hook before deleting
    if (typeof (this as any).beforeDelete === 'function') {
      await (this as any).beforeDelete();
    }
    // perform deletion
    const result = await this.collection.delete(this, { session: opts?.session });
    // allow hook after delete
    if (typeof (this as any).afterDelete === 'function') {
      await (this as any).afterDelete();
    }
    return result;
  }

  /**
   * also store any referenced objects to DB
   * better for data consistency
   */
  public saveDeep(savedMapArg: plugins.lik.ObjectMap<SmartDataDbDoc<any, any>> = null) {
    if (!savedMapArg) {
      savedMapArg = new plugins.lik.ObjectMap<SmartDataDbDoc<any, any>>();
    }
    savedMapArg.add(this);
    this.save();
    for (const propertyKey of Object.keys(this)) {
      const property: any = this[propertyKey];
      if (property instanceof SmartDataDbDoc && !savedMapArg.checkForObject(property)) {
        property.saveDeep(savedMapArg);
      }
    }
  }

  /**
   * updates an object from db
   */
  public async updateFromDb() {
    const mongoDbNativeDoc = await this.collection.findOne(await this.createIdentifiableObject());
    for (const key of Object.keys(mongoDbNativeDoc)) {
      const rawValue = mongoDbNativeDoc[key];
      const optionsMap = (this.constructor as any)._svDbOptions || {};
      const opts = optionsMap[key];
      this[key] = opts && typeof opts.deserialize === 'function'
        ? opts.deserialize(rawValue)
        : rawValue;
    }
  }

  /**
   * creates a saveable object so the instance can be persisted as json in the database
   */
  public async createSavableObject(): Promise<TImplements> {
    const saveableObject: unknown = {}; // is not exposed to outside, so any is ok here
    const saveableProperties = [...this.globalSaveableProperties, ...this.saveableProperties];
    // apply custom serialization if configured
    const optionsMap = (this.constructor as any)._svDbOptions || {};
    for (const propertyNameString of saveableProperties) {
      const rawValue = (this as any)[propertyNameString];
      const opts = optionsMap[propertyNameString];
      (saveableObject as any)[propertyNameString] = opts && typeof opts.serialize === 'function'
        ? opts.serialize(rawValue)
        : rawValue;
    }
    return saveableObject as TImplements;
  }

  /**
   * creates an identifiable object for operations that require filtering
   */
  public async createIdentifiableObject() {
    const identifiableObject: any = {}; // is not exposed to outside, so any is ok here
    for (const propertyNameString of this.uniqueIndexes) {
      identifiableObject[propertyNameString] = this[propertyNameString];
    }
    return identifiableObject;
  }
}