import * as plugins from './plugins.js';
import { SmartdataDb } from './classes.db.js';
import { SmartdataDbCursor } from './classes.cursor.js';
import { SmartDataDbDoc, type IIndexOptions } from './classes.doc.js';
import { SmartdataDbWatcher } from './classes.watcher.js';
import { CollectionFactory } from './classes.collectionfactory.js';
import { logger } from './logging.js';

export interface IFindOptions {
  limit?: number;
}

/**
 *
 */
export interface IDocValidationFunc<T> {
  (doc: T): boolean;
}

export type TDelayed<TDelayedArg> = () => TDelayedArg;

const collectionFactory = new CollectionFactory();

interface ISmartdataDecoratorMetadata {
  globalSaveableProperties?: string[];
  saveableProperties?: string[];
  uniqueIndexes?: string[];
  regularIndexes?: Array<{field: string, options: IIndexOptions}>;
  searchableFields?: string[];
  _svDbOptions?: Record<string, any>;
}

const getOwnMetadataValue = <T>(metadata: any, key: keyof ISmartdataDecoratorMetadata): T | undefined => {
  if (!metadata || !Object.prototype.hasOwnProperty.call(metadata, key)) {
    return undefined;
  }
  return metadata[key] as T;
};

const mergeStringArrays = (...arrays: Array<string[] | undefined>): string[] => {
  const merged: string[] = [];
  for (const array of arrays) {
    if (!array) {
      continue;
    }
    for (const item of array) {
      if (!merged.includes(item)) {
        merged.push(item);
      }
    }
  }
  return merged;
};

const mergeRegularIndexes = (
  ...indexArrays: Array<Array<{field: string, options: IIndexOptions}> | undefined>
): Array<{field: string, options: IIndexOptions}> => {
  const mergedMap = new Map<string, {field: string, options: IIndexOptions}>();
  for (const indexArray of indexArrays) {
    if (!indexArray) {
      continue;
    }
    for (const indexDef of indexArray) {
      mergedMap.set(indexDef.field, {
        field: indexDef.field,
        options: { ...indexDef.options },
      });
    }
  }
  return [...mergedMap.values()];
};

const mergeDecoratorMetadata = (
  ...metadataArgs: Array<ISmartdataDecoratorMetadata | undefined>
): ISmartdataDecoratorMetadata => {
  const merged: ISmartdataDecoratorMetadata = {};

  merged.globalSaveableProperties = mergeStringArrays(
    ...metadataArgs.map((metadataArg) => getOwnMetadataValue<string[]>(metadataArg, 'globalSaveableProperties')),
  );
  merged.saveableProperties = mergeStringArrays(
    ...metadataArgs.map((metadataArg) => getOwnMetadataValue<string[]>(metadataArg, 'saveableProperties')),
  );
  merged.uniqueIndexes = mergeStringArrays(
    ...metadataArgs.map((metadataArg) => getOwnMetadataValue<string[]>(metadataArg, 'uniqueIndexes')),
  );
  merged.searchableFields = mergeStringArrays(
    ...metadataArgs.map((metadataArg) => getOwnMetadataValue<string[]>(metadataArg, 'searchableFields')),
  );
  merged.regularIndexes = mergeRegularIndexes(
    ...metadataArgs.map((metadataArg) => getOwnMetadataValue<Array<{field: string, options: IIndexOptions}>>(metadataArg, 'regularIndexes')),
  );

  const svDbOptions: Record<string, any> = {};
  for (const metadataArg of metadataArgs) {
    const options = getOwnMetadataValue<Record<string, any>>(metadataArg, '_svDbOptions');
    if (options) {
      Object.assign(svDbOptions, options);
    }
  }
  if (Object.keys(svDbOptions).length > 0) {
    merged._svDbOptions = svDbOptions;
  }

  return merged;
};

const collectInheritedDecoratorMetadata = (
  constructor: { new (...args: any[]): any; prototype: any },
): ISmartdataDecoratorMetadata => {
  const metadataChain: ISmartdataDecoratorMetadata[] = [];
  let proto = Object.getPrototypeOf(constructor.prototype);
  while (proto && proto !== Object.prototype) {
    const metadata = proto.constructor?.[Symbol.metadata] as ISmartdataDecoratorMetadata | undefined;
    if (metadata) {
      metadataChain.unshift(metadata);
    }
    proto = Object.getPrototypeOf(proto);
  }
  return mergeDecoratorMetadata(...metadataChain);
};

/**
 * Initialize prototype and constructor properties from TC39 decorator metadata.
 * Shared by both Collection and managed decorators.
 */
function initializeDecoratorMetadata(
  constructor: { new (...args: any[]): any; prototype: any },
  metadata: any
): void {
  const mergedMetadata = mergeDecoratorMetadata(
    collectInheritedDecoratorMetadata(constructor),
    metadata as ISmartdataDecoratorMetadata | undefined,
  );

  const proto = constructor.prototype;
  const ctor = constructor as any;

  // Prototype properties (instance-level)
  if (mergedMetadata.globalSaveableProperties?.length) {
    proto.globalSaveableProperties = [...mergedMetadata.globalSaveableProperties];
  }
  if (mergedMetadata.saveableProperties?.length) {
    proto.saveableProperties = [...mergedMetadata.saveableProperties];
  }
  if (mergedMetadata.uniqueIndexes?.length) {
    proto.uniqueIndexes = [...mergedMetadata.uniqueIndexes];
  }
  if (mergedMetadata.regularIndexes?.length) {
    proto.regularIndexes = [...mergedMetadata.regularIndexes];
  }

  // Constructor properties (static-level)
  if (mergedMetadata.searchableFields?.length) {
    ctor.searchableFields = [...mergedMetadata.searchableFields];
  }
  if (mergedMetadata._svDbOptions) {
    ctor._svDbOptions = { ...mergedMetadata._svDbOptions };
  }
}

/**
 * This is a decorator that will tell the decorated class what dbTable to use
 * @param dbArg
 */
export function Collection(dbArg: SmartdataDb | TDelayed<SmartdataDb>) {
  return function classDecorator(value: Function, context: ClassDecoratorContext) {
    if (context.kind !== 'class') {
      throw new Error('Collection can only decorate classes');
    }

    const constructor = value as { new (...args: any[]): any } & { className?: string };

    const getCollection = () => {
      if (!(dbArg instanceof SmartdataDb)) {
        dbArg = dbArg();
      }
      const coll = collectionFactory.getCollection(constructor.name, dbArg);
      // Attach document constructor for searchableFields lookup
      if (coll && !(coll as any).docCtor) {
        (coll as any).docCtor = constructor;
      }
      return coll;
    };

    // Add static className property directly on the constructor
    (constructor as any).className = constructor.name;

    // Define collection getter on constructor (static access)
    Object.defineProperty(constructor, 'collection', {
      get: getCollection,
      enumerable: false,
      configurable: true
    });

    // Define collection getter on prototype (instance access)
    Object.defineProperty(constructor.prototype, 'collection', {
      get: getCollection,
      enumerable: false,
      configurable: true
    });

    initializeDecoratorMetadata(constructor, context.metadata);
    return constructor as any;
  };
}

export interface IManager {
  db: SmartdataDb;
}

export const setDefaultManagerForDoc = <T,>(managerArg: IManager, dbDocArg: T): T => {
  (dbDocArg as any).prototype.defaultManager = managerArg;
  return dbDocArg;
};

/**
 * This is a decorator that will tell the decorated class what dbTable to use
 * @param dbArg
 */
export function managed<TManager extends IManager>(managerArg?: TManager | TDelayed<TManager>) {
  return function classDecorator(value: Function, context: ClassDecoratorContext) {
    if (context.kind !== 'class') {
      throw new Error('managed can only decorate classes');
    }

    const constructor = value as { new (...args: any[]): any } & { className?: string };
    (constructor as any).className = constructor.name;

    // Resolution helpers (capture managerArg via closure)
    const getManager = (defaultManagerFn: () => TManager): TManager => {
      if (!managerArg) return defaultManagerFn();
      if (managerArg['db']) return managerArg as TManager;
      return (managerArg as TDelayed<TManager>)();
    };

    const getDb = (defaultManagerFn: () => TManager): SmartdataDb => {
      return getManager(defaultManagerFn).db;
    };

    // Static getters
    Object.defineProperty(constructor, 'collection', {
      get(this: any) { return collectionFactory.getCollection(constructor.name, getDb(() => this.prototype.defaultManager)); },
      enumerable: false,
      configurable: true
    });
    Object.defineProperty(constructor, 'manager', {
      get(this: any) { return getManager(() => this.prototype.defaultManager); },
      enumerable: false,
      configurable: true
    });

    // Instance getters
    Object.defineProperty(constructor.prototype, 'collection', {
      get(this: any) { return collectionFactory.getCollection(constructor.name, getDb(() => this.defaultManager)); },
      enumerable: false,
      configurable: true
    });
    Object.defineProperty(constructor.prototype, 'manager', {
      get(this: any) { return getManager(() => this.defaultManager); },
      enumerable: false,
      configurable: true
    });

    initializeDecoratorMetadata(constructor, context.metadata);
    return constructor as any;
  };
}

/**
 * @dpecrecated use @managed instead
 */
export const Manager = managed;

export class SmartdataCollection<T> {
  /**
   * the collection that is used
   */
  public mongoDbCollection!: plugins.mongodb.Collection;
  public objectValidation: IDocValidationFunc<T> | null = null;
  public collectionName: string;
  public smartdataDb: SmartdataDb;
  public uniqueIndexes: string[] = [];
  public regularIndexes: Array<{field: string, options: IIndexOptions}> = [];
  // flag to ensure text index is created only once
  private textIndexCreated: boolean = false;

  constructor(classNameArg: string, smartDataDbArg: SmartdataDb) {
    // tell the collection where it belongs
    this.collectionName = classNameArg;
    this.smartdataDb = smartDataDbArg;

    // tell the db class about it (important since Db uses different systems under the hood)
    this.smartdataDb.addCollection(this);
  }

  /**
   * makes sure a collection exists within MongoDb that maps to the SmartdataCollection
   */
  public async init() {
    if (!this.mongoDbCollection) {
      // connect this instance to a MongoDB collection
      const availableMongoDbCollections = await this.smartdataDb.mongoDb.collections();
      const wantedCollection = availableMongoDbCollections.find((collection) => {
        return collection.collectionName === this.collectionName;
      });
      if (!wantedCollection) {
        await this.smartdataDb.mongoDb.createCollection(this.collectionName);
        logger.log('info', `Successfully initiated Collection ${this.collectionName}`);
      }
      this.mongoDbCollection = this.smartdataDb.mongoDb.collection(this.collectionName);
      // Auto-create a compound text index on all searchable fields
      // Use document constructor's searchableFields registered via decorator
      const docCtor = (this as any).docCtor;
      const searchableFields: string[] = docCtor?.searchableFields || [];
      if (searchableFields.length > 0 && !this.textIndexCreated) {
        // Build a compound text index spec
        const indexSpec: Record<string, 'text'> = {};
        searchableFields.forEach(f => { indexSpec[f] = 'text'; });
        // Cast to any to satisfy TypeScript IndexSpecification typing
        try {
          await this.mongoDbCollection.createIndex(indexSpec as any, { name: 'smartdata_text_index' });
          this.textIndexCreated = true;
        } catch (err: any) {
          logger.log(
            'warn',
            `Failed to create text index on fields [${searchableFields.join(', ')}] in collection "${this.collectionName}": ${err?.message || String(err)}`
          );
        }
      }
    }
  }

  /**
   * mark unique index
   */
  public async markUniqueIndexes(keyArrayArg: string[] = []) {
    for (const key of keyArrayArg) {
      if (!this.uniqueIndexes.includes(key)) {
        // Claim the slot immediately to prevent concurrent inserts from retrying
        this.uniqueIndexes.push(key);
        try {
          await this.mongoDbCollection.createIndex({ [key]: 1 }, {
            unique: true,
          });
        } catch (err: any) {
          const errorCode = err?.code || err?.codeName || 'unknown';
          const errorMessage = err?.message || String(err);
          logger.log(
            'error',
            `Failed to create unique index on field "${key}" in collection "${this.collectionName}". ` +
            `MongoDB error [${errorCode}]: ${errorMessage}. ` +
            `Uniqueness constraint on "${key}" is NOT enforced.`
          );
          if (errorCode === 11000 || errorCode === 'DuplicateKey' || String(errorMessage).includes('E11000')) {
            await this.logDuplicatesForField(key);
          }
        }
      }
    }
  }

  /**
   * creates regular indexes for the collection
   */
  public async createRegularIndexes(indexesArg: Array<{field: string, options: IIndexOptions}> = []) {
    for (const indexDef of indexesArg) {
      // Check if we've already created this index
      const indexKey = indexDef.field;
      if (!this.regularIndexes.some(i => i.field === indexKey)) {
        // Claim the slot immediately to prevent concurrent retries
        this.regularIndexes.push(indexDef);
        try {
          await this.mongoDbCollection.createIndex(
            { [indexDef.field]: 1 }, // Simple single-field index
            indexDef.options
          );
        } catch (err: any) {
          const errorCode = err?.code || err?.codeName || 'unknown';
          const errorMessage = err?.message || String(err);
          logger.log(
            'warn',
            `Failed to create index on field "${indexKey}" in collection "${this.collectionName}". ` +
            `MongoDB error [${errorCode}]: ${errorMessage}.`
          );
          if (
            indexDef.options?.unique &&
            (errorCode === 11000 || errorCode === 'DuplicateKey' || String(errorMessage).includes('E11000'))
          ) {
            await this.logDuplicatesForField(indexKey);
          }
        }
      }
    }
  }

  /**
   * Logs duplicate values for a field to help diagnose unique index creation failures.
   */
  private async logDuplicatesForField(field: string): Promise<void> {
    try {
      const pipeline = [
        { $group: { _id: `$${field}`, count: { $sum: 1 }, ids: { $push: '$_id' } } },
        { $match: { count: { $gt: 1 } } },
        { $limit: 5 },
      ];
      const duplicates = await this.mongoDbCollection.aggregate(pipeline).toArray();
      if (duplicates.length > 0) {
        for (const dup of duplicates) {
          logger.log(
            'warn',
            `Duplicate values for "${field}" in "${this.collectionName}": ` +
            `value=${JSON.stringify(dup._id)} appears ${dup.count} times ` +
            `(document _ids: ${JSON.stringify(dup.ids.slice(0, 5))})`
          );
        }
        logger.log(
          'warn',
          `Unique index on "${field}" in "${this.collectionName}" was NOT created. ` +
          `Resolve duplicates and restart to enforce uniqueness.`
        );
      }
    } catch (aggErr: any) {
      logger.log(
        'warn',
        `Could not identify duplicate documents for field "${field}" in "${this.collectionName}": ${aggErr?.message || String(aggErr)}`
      );
    }
  }

  /**
   * adds a validation function that all newly inserted and updated objects have to pass
   */
  public addDocValidation(funcArg: IDocValidationFunc<T>) {
    this.objectValidation = funcArg;
  }

  /**
   * finds an object in the DbCollection
   */
  public async findOne(
    filterObject: any,
    opts?: { session?: plugins.mongodb.ClientSession }
  ): Promise<any> {
    await this.init();
    // Use MongoDB driver's findOne with optional session
    return this.mongoDbCollection.findOne(filterObject, { session: opts?.session });
  }

  public async getCursor(
    filterObjectArg: any,
    dbDocArg: typeof SmartDataDbDoc,
    opts?: { session?: plugins.mongodb.ClientSession }
  ): Promise<SmartdataDbCursor<any>> {
    await this.init();
    const cursor = this.mongoDbCollection.find(filterObjectArg, { session: opts?.session });
    return new SmartdataDbCursor(cursor, dbDocArg);
  }

  /**
   * finds an object in the DbCollection
   */
  public async findAll(
    filterObject: any,
    opts?: { session?: plugins.mongodb.ClientSession }
  ): Promise<any[]> {
    await this.init();
    const cursor = this.mongoDbCollection.find(filterObject, { session: opts?.session });
    const result = await cursor.toArray();
    cursor.close();

    // In-memory check for duplicate _id values (should never happen)
    if (result.length > 0) {
      const idSet = new Set<string>();
      const duplicateIds: string[] = [];
      for (const doc of result) {
        const idStr = String(doc._id);
        if (idSet.has(idStr)) {
          duplicateIds.push(idStr);
        } else {
          idSet.add(idStr);
        }
      }
      if (duplicateIds.length > 0) {
        logger.log(
          'error',
          `Integrity issue in "${this.collectionName}": found ${duplicateIds.length} duplicate _id values ` +
          `in findAll results: [${duplicateIds.slice(0, 5).join(', ')}]. This should never happen.`
        );
      }
    }

    return result;
  }

  /**
   * Watches the collection, returning a SmartdataDbWatcher with RxJS and EventEmitter support.
   * @param filterObject match filter for change stream
   * @param opts optional MongoDB ChangeStreamOptions & { bufferTimeMs } to buffer events
   * @param smartdataDbDocArg document class for instance creation
   */
  public async watch(
    filterObject: any,
    opts: (plugins.mongodb.ChangeStreamOptions & { bufferTimeMs?: number }) = {},
    smartdataDbDocArg?: typeof SmartDataDbDoc,
  ): Promise<SmartdataDbWatcher> {
    await this.init();
    // Extract bufferTimeMs from options
    const { bufferTimeMs, fullDocument, ...otherOptions } = opts || {};
    // Determine fullDocument behavior: default to 'updateLookup'
    const changeStreamOptions: plugins.mongodb.ChangeStreamOptions = {
      ...otherOptions,
      fullDocument:
        fullDocument === undefined
          ? 'updateLookup'
          : (fullDocument as any) === true
          ? 'updateLookup'
          : fullDocument,
    } as any;
    // Build pipeline with match if provided
    const pipeline = filterObject ? [{ $match: filterObject }] : [];
    const changeStream = this.mongoDbCollection.watch(
      pipeline,
      changeStreamOptions,
    );
    const smartdataWatcher = new SmartdataDbWatcher(
      changeStream,
      smartdataDbDocArg!,
      { bufferTimeMs },
    );
    await smartdataWatcher.readyDeferred.promise;
    return smartdataWatcher;
  }

  /**
   * create an object in the database
   */
  public async insert(
    dbDocArg: T & SmartDataDbDoc<T, unknown>,
    opts?: { session?: plugins.mongodb.ClientSession }
  ): Promise<any> {
    await this.init();
    await this.checkDoc(dbDocArg);
    await this.markUniqueIndexes(dbDocArg.uniqueIndexes);

    // Create regular indexes if available
    if (dbDocArg.regularIndexes && dbDocArg.regularIndexes.length > 0) {
      await this.createRegularIndexes(dbDocArg.regularIndexes);
    }
    
    const saveableObject = await dbDocArg.createSavableObject() as any;
    try {
      const result = await this.mongoDbCollection.insertOne(saveableObject, { session: opts?.session });
      return result;
    } catch (err: any) {
      const isDuplicateKey = err?.code === 11000 || err?.codeName === 'DuplicateKey';
      if (isDuplicateKey && dbDocArg.uniqueIndexes && dbDocArg.uniqueIndexes.length > 0) {
        const identifiableObject = await dbDocArg.createIdentifiableObject();
        logger.log(
          'error',
          `Duplicate key conflict in "${this.collectionName}" on insert. ` +
          `A document with ${JSON.stringify(identifiableObject)} already exists. ` +
          `Use getInstance() to retrieve the existing document, or update it via save() on a db-retrieved instance.`
        );
      }
      throw err;
    }
  }

  /**
   * inserts object into the DbCollection
   */
  public async update(
    dbDocArg: T & SmartDataDbDoc<T, unknown>,
    opts?: { session?: plugins.mongodb.ClientSession }
  ): Promise<any> {
    await this.init();
    await this.checkDoc(dbDocArg);
    await this.markUniqueIndexes(dbDocArg.uniqueIndexes);
    if (dbDocArg.regularIndexes && dbDocArg.regularIndexes.length > 0) {
      await this.createRegularIndexes(dbDocArg.regularIndexes);
    }
    const identifiableObject = await dbDocArg.createIdentifiableObject();
    const saveableObject = await dbDocArg.createSavableObject() as any;
    const updateableObject: any = {};
    for (const key of Object.keys(saveableObject)) {
      if (identifiableObject[key]) {
        continue;
      }
      updateableObject[key] = saveableObject[key];
    }
    const result = await this.mongoDbCollection.updateOne(
      identifiableObject,
      { $set: updateableObject },
      { upsert: true, session: opts?.session },
    );
    return result;
  }

  public async delete(
    dbDocArg: T & SmartDataDbDoc<T, unknown>,
    opts?: { session?: plugins.mongodb.ClientSession }
  ): Promise<any> {
    await this.init();
    await this.checkDoc(dbDocArg);
    const identifiableObject = await dbDocArg.createIdentifiableObject();
    await this.mongoDbCollection.deleteOne(identifiableObject, { session: opts?.session });
  }

  public async getCount(
    filterObject: any,
    opts?: { session?: plugins.mongodb.ClientSession }
  ) {
    await this.init();
    return this.mongoDbCollection.countDocuments(filterObject, { session: opts?.session });
  }

  /**
   * Runs an integrity check on the collection.
   * Compares estimated vs actual document count and checks for duplicates on unique index fields.
   */
  public async checkCollectionIntegrity(): Promise<{
    ok: boolean;
    estimatedCount: number;
    actualCount: number;
    duplicateFields: Array<{ field: string; duplicateValues: number }>;
  }> {
    await this.init();
    const result = {
      ok: true,
      estimatedCount: 0,
      actualCount: 0,
      duplicateFields: [] as Array<{ field: string; duplicateValues: number }>,
    };

    try {
      result.estimatedCount = await this.mongoDbCollection.estimatedDocumentCount();
      result.actualCount = await this.mongoDbCollection.countDocuments({});

      if (result.estimatedCount !== result.actualCount) {
        result.ok = false;
        logger.log(
          'warn',
          `Integrity check on "${this.collectionName}": estimatedDocumentCount=${result.estimatedCount} ` +
          `but countDocuments=${result.actualCount}. Possible data inconsistency.`
        );
      }

      // Check for duplicates on each tracked unique index field
      for (const field of this.uniqueIndexes) {
        try {
          const pipeline = [
            { $group: { _id: `$${field}`, count: { $sum: 1 } } },
            { $match: { count: { $gt: 1 } } },
            { $count: 'total' },
          ];
          const countResult = await this.mongoDbCollection.aggregate(pipeline).toArray();
          const dupCount = countResult[0]?.total || 0;
          if (dupCount > 0) {
            result.ok = false;
            result.duplicateFields.push({ field, duplicateValues: dupCount });
            logger.log(
              'warn',
              `Integrity check on "${this.collectionName}": field "${field}" has ${dupCount} values with duplicates ` +
              `despite being marked as unique.`
            );
          }
        } catch (fieldErr: any) {
          logger.log(
            'warn',
            `Integrity check: could not verify uniqueness of "${field}" in "${this.collectionName}": ${fieldErr?.message || String(fieldErr)}`
          );
        }
      }
    } catch (err: any) {
      result.ok = false;
      logger.log(
        'error',
        `Integrity check failed for "${this.collectionName}": ${err?.message || String(err)}`
      );
    }

    return result;
  }

  /**
   * checks a Doc for constraints
   * if this.objectValidation is not set it passes.
   */
  private checkDoc(docArg: T): Promise<void> {
    const done = plugins.smartpromise.defer<void>();
    let validationResult = true;
    if (this.objectValidation) {
      validationResult = this.objectValidation(docArg);
    }
    if (validationResult) {
      done.resolve();
    } else {
      done.reject('validation of object did not pass');
    }
    return done.promise;
  }
}
