import { EntityManagerType } from './IDatabaseDriver.js';
import { Utils } from '../utils/Utils.js';
import { Cursor } from '../utils/Cursor.js';
import { EntityComparator } from '../utils/EntityComparator.js';
import { isRaw, raw } from '../utils/RawQueryFragment.js';
import { QueryOrderNumeric, ReferenceKind } from '../enums.js';
import { EntityManager } from '../EntityManager.js';
import { CursorError, ValidationError } from '../errors.js';
import { DriverException } from '../exceptions.js';
import { helper } from '../entity/wrap.js';
import { PolymorphicRef } from '../entity/PolymorphicRef.js';
import { JsonType } from '../types/JsonType.js';
import { MikroORM } from '../MikroORM.js';
/** Abstract base class for all database drivers, implementing common driver logic. */
export class DatabaseDriver {
  config;
  dependencies;
  [EntityManagerType];
  connection;
  replicas = [];
  platform;
  comparator;
  metadata;
  constructor(config, dependencies) {
    this.config = config;
    this.dependencies = dependencies;
  }
  async nativeUpdateMany(entityName, where, data, options) {
    throw new Error(`Batch updates are not supported by ${this.constructor.name} driver`);
  }
  /** Creates a new EntityManager instance bound to this driver. */
  createEntityManager(useContext) {
    const EntityManagerClass = this.config.get('entityManager', EntityManager);
    return new EntityManagerClass(this.config, this, this.metadata, useContext);
  }
  /* v8 ignore next */
  async findVirtual(entityName, where, options) {
    throw new Error(`Virtual entities are not supported by ${this.constructor.name} driver.`);
  }
  /* v8 ignore next */
  async countVirtual(entityName, where, options) {
    throw new Error(`Counting virtual entities is not supported by ${this.constructor.name} driver.`);
  }
  async aggregate(entityName, pipeline) {
    throw new Error(`Aggregations are not supported by ${this.constructor.name} driver`);
  }
  async loadFromPivotTable(prop, owners, where, orderBy, ctx, options, pivotJoin) {
    throw new Error(`${this.constructor.name} does not use pivot tables`);
  }
  async syncCollections(collections, options) {
    for (const coll of collections) {
      /* v8 ignore else */
      if (!coll.property.owner) {
        if (coll.getSnapshot() === undefined) {
          throw ValidationError.cannotModifyInverseCollection(coll.owner, coll.property);
        }
        continue;
      }
      /* v8 ignore next */
      {
        const pk = coll.property.targetMeta.primaryKeys[0];
        const data = { [coll.property.name]: coll.getIdentifiers(pk) };
        await this.nativeUpdate(coll.owner.constructor, helper(coll.owner).getPrimaryKey(), data, options);
      }
    }
  }
  /** Maps raw database result to entity data, converting column names to property names. */
  mapResult(result, meta, populate = []) {
    if (!result || !meta) {
      return result ?? null;
    }
    return this.comparator.mapResult(meta, result);
  }
  /** Opens the primary connection and all read replicas. */
  async connect(options) {
    await this.connection.connect(options);
    await Promise.all(this.replicas.map(replica => replica.connect()));
    return this.connection;
  }
  /** Closes all connections and re-establishes them. */
  async reconnect(options) {
    await this.close(true);
    await this.connect(options);
    return this.connection;
  }
  /** Returns the write connection or a random read replica. */
  getConnection(type = 'write') {
    if (type === 'write' || this.replicas.length === 0) {
      return this.connection;
    }
    const rand = Utils.randomInt(0, this.replicas.length - 1);
    return this.replicas[rand];
  }
  /** Closes the primary connection and all read replicas. */
  async close(force) {
    await Promise.all(this.replicas.map(replica => replica.close(force)));
    await this.connection.close(force);
  }
  /** Returns the database platform abstraction for this driver. */
  getPlatform() {
    return this.platform;
  }
  /** Sets the metadata storage and initializes the comparator for all connections. */
  setMetadata(metadata) {
    this.metadata = metadata;
    this.comparator = new EntityComparator(this.metadata, this.platform);
    this.connection.setMetadata(metadata);
    this.connection.setPlatform(this.platform);
    this.replicas.forEach(replica => {
      replica.setMetadata(metadata);
      replica.setPlatform(this.platform);
    });
  }
  /** Returns the metadata storage used by this driver. */
  getMetadata() {
    return this.metadata;
  }
  /** Returns the names of native database dependencies required by this driver. */
  getDependencies() {
    return this.dependencies;
  }
  isPopulated(meta, prop, hint, name) {
    if (hint.field === prop.name || hint.field === name || hint.all) {
      return true;
    }
    if (prop.embedded && hint.children && meta.properties[prop.embedded[0]].name === hint.field) {
      return hint.children.some(c => this.isPopulated(meta, prop, c, prop.embedded[1]));
    }
    return false;
  }
  processCursorOptions(meta, options, orderBy) {
    const { first, last, before, after, overfetch } = options;
    const limit = first ?? last;
    const isLast = !first && !!last;
    const definition = Cursor.getDefinition(meta, orderBy);
    const $and = [];
    // allow POJO as well, we care only about the correct key being present
    const isCursor = (val, key) => {
      return !!val && typeof val === 'object' && key in val;
    };
    const createCursor = (val, key, inverse = false) => {
      let def = isCursor(val, key) ? val[key] : val;
      if (Utils.isPlainObject(def)) {
        def = Cursor.for(meta, def, orderBy);
      }
      /* v8 ignore next */
      const offsets = def ? Cursor.decode(def) : [];
      if (definition.length === offsets.length) {
        return this.createCursorCondition(definition, offsets, inverse, meta);
      }
      /* v8 ignore next */
      return {};
    };
    if (after) {
      $and.push(createCursor(after, 'endCursor'));
    }
    if (before) {
      $and.push(createCursor(before, 'startCursor', true));
    }
    if (limit != null) {
      options.limit = limit + (overfetch ? 1 : 0);
    }
    const createOrderBy = (prop, direction) => {
      if (Utils.isPlainObject(direction)) {
        const value = Utils.getObjectQueryKeys(direction).reduce((o, key) => {
          Object.assign(o, createOrderBy(key, direction[key]));
          return o;
        }, {});
        return { [prop]: value };
      }
      const desc = direction === QueryOrderNumeric.DESC || direction.toString().toLowerCase() === 'desc';
      const dir = Utils.xor(desc, isLast) ? 'desc' : 'asc';
      return { [prop]: dir };
    };
    return {
      orderBy: definition.map(([prop, direction]) => createOrderBy(prop, direction)),
      where: $and.length > 1 ? { $and } : { ...$and[0] },
    };
  }
  createCursorCondition(definition, offsets, inverse, meta) {
    const createCondition = (prop, direction, offset, eq = false, path = prop) => {
      if (Utils.isPlainObject(direction)) {
        if (offset === undefined) {
          throw CursorError.missingValue(meta.className, path);
        }
        const value = Utils.keys(direction).reduce((o, key) => {
          Object.assign(o, createCondition(key, direction[key], offset?.[key], eq, `${path}.${key}`));
          return o;
        }, {});
        return { [prop]: value };
      }
      const isDesc = direction === QueryOrderNumeric.DESC || direction.toString().toLowerCase() === 'desc';
      const dirStr = direction.toString().toLowerCase();
      let nullsFirst;
      if (dirStr.includes('nulls first')) {
        nullsFirst = true;
      } else if (dirStr.includes('nulls last')) {
        nullsFirst = false;
      } else {
        // Default: NULLS LAST for ASC, NULLS FIRST for DESC (matches most databases)
        nullsFirst = isDesc;
      }
      const operator = Utils.xor(isDesc, inverse) ? '$lt' : '$gt';
      // For leaf-level properties, undefined means missing value
      if (offset === undefined) {
        throw CursorError.missingValue(meta.className, path);
      }
      // Handle null offset (intentional null cursor value)
      if (offset === null) {
        if (eq) {
          // Equal to null
          return { [prop]: null };
        }
        // Strict comparison with null cursor value
        // hasItemsAfterNull: forward + nullsFirst, or backward + nullsLast
        const hasItemsAfterNull = Utils.xor(nullsFirst, inverse);
        if (hasItemsAfterNull) {
          return { [prop]: { $ne: null } };
        }
        // No items after null in this direction, return impossible condition
        return { [prop]: [] };
      }
      // Non-null offset
      return { [prop]: { [operator + (eq ? 'e' : '')]: offset } };
    };
    const [order, ...otherOrders] = definition;
    const [offset, ...otherOffsets] = offsets;
    const [prop, direction] = order;
    if (!otherOrders.length) {
      return createCondition(prop, direction, offset);
    }
    return {
      ...createCondition(prop, direction, offset, true),
      $or: [
        createCondition(prop, direction, offset),
        this.createCursorCondition(otherOrders, otherOffsets, inverse, meta),
      ],
    };
  }
  /** @internal */
  mapDataToFieldNames(data, stringifyJsonArrays, properties, convertCustomTypes, object) {
    if (!properties || data == null) {
      return data;
    }
    data = Object.assign({}, data); // copy first
    Object.keys(data).forEach(k => {
      const prop = properties[k];
      if (!prop) {
        return;
      }
      if (prop.embeddedProps && !prop.object && !object) {
        const copy = data[k];
        delete data[k];
        Object.assign(
          data,
          this.mapDataToFieldNames(copy, stringifyJsonArrays, prop.embeddedProps, convertCustomTypes),
        );
        return;
      }
      if (prop.embeddedProps && (object || prop.object)) {
        const copy = data[k];
        delete data[k];
        if (prop.array) {
          data[prop.fieldNames[0]] = copy?.map(item =>
            this.mapDataToFieldNames(item, stringifyJsonArrays, prop.embeddedProps, convertCustomTypes, true),
          );
        } else {
          data[prop.fieldNames[0]] = this.mapDataToFieldNames(
            copy,
            stringifyJsonArrays,
            prop.embeddedProps,
            convertCustomTypes,
            true,
          );
        }
        if (stringifyJsonArrays && prop.array && !object) {
          data[prop.fieldNames[0]] = this.platform.convertJsonToDatabaseValue(data[prop.fieldNames[0]]);
        }
        return;
      }
      // Handle polymorphic relations - convert tuple or PolymorphicRef to separate columns
      // Tuple format: ['discriminator', id] or ['discriminator', id1, id2] for composite keys
      // Must be checked BEFORE joinColumns array handling since polymorphic uses fieldNames (includes discriminator)
      if (prop.polymorphic && prop.fieldNames && prop.fieldNames.length >= 2) {
        let discriminator;
        let ids;
        if (Array.isArray(data[k]) && typeof data[k][0] === 'string' && prop.discriminatorMap?.[data[k][0]]) {
          // Tuple format: ['discriminator', ...ids]
          const [disc, ...rest] = data[k];
          discriminator = disc;
          ids = rest;
        } else if (data[k] instanceof PolymorphicRef) {
          // PolymorphicRef wrapper (internal use)
          discriminator = data[k].discriminator;
          const polyId = data[k].id;
          // Handle object-style composite key IDs like { tenantId: 1, orgId: 100 }
          if (polyId && typeof polyId === 'object' && !Array.isArray(polyId)) {
            const targetEntity = prop.discriminatorMap?.[discriminator];
            const targetMeta = this.metadata.get(targetEntity);
            ids = targetMeta.primaryKeys.map(pk => polyId[pk]);
          } else {
            ids = Utils.asArray(polyId);
          }
        }
        if (discriminator) {
          const discriminatorColumn = prop.fieldNames[0];
          const idColumns = prop.fieldNames.slice(1);
          delete data[k];
          data[discriminatorColumn] = discriminator;
          idColumns.forEach((col, idx) => {
            data[col] = ids[idx];
          });
          return;
        }
      }
      if (prop.joinColumns && Array.isArray(data[k])) {
        const copy = Utils.flatten(data[k]);
        delete data[k];
        prop.joinColumns.forEach((joinColumn, idx) => (data[joinColumn] = copy[idx]));
        return;
      }
      if (prop.joinColumns?.length > 1 && data[k] == null) {
        delete data[k];
        prop.ownColumns.forEach(joinColumn => (data[joinColumn] = null));
        return;
      }
      if (
        prop.customType &&
        convertCustomTypes &&
        !(prop.customType instanceof JsonType && object) &&
        !isRaw(data[k])
      ) {
        data[k] = prop.customType.convertToDatabaseValue(data[k], this.platform, {
          fromQuery: true,
          key: k,
          mode: 'query-data',
        });
      }
      if (prop.hasConvertToDatabaseValueSQL && !prop.object && !isRaw(data[k])) {
        const quoted = this.platform.quoteValue(data[k]);
        const sql = prop.customType.convertToDatabaseValueSQL(quoted, this.platform);
        data[k] = raw(sql.replace(/\?/g, '\\?'));
      }
      if (prop.fieldNames) {
        Utils.renameKey(data, k, prop.fieldNames[0]);
      }
    });
    return data;
  }
  inlineEmbeddables(meta, data, where) {
    /* v8 ignore next */
    if (data == null) {
      return;
    }
    Utils.keys(data).forEach(k => {
      if (Utils.isOperator(k)) {
        Utils.asArray(data[k]).forEach(payload => this.inlineEmbeddables(meta, payload, where));
      }
    });
    meta.props.forEach(prop => {
      if (prop.kind === ReferenceKind.EMBEDDED && prop.object && !where && Utils.isObject(data[prop.name])) {
        return;
      }
      if (prop.kind === ReferenceKind.EMBEDDED && Utils.isObject(data[prop.name])) {
        const props = prop.embeddedProps;
        let unknownProp = false;
        Object.keys(data[prop.name]).forEach(kk => {
          // explicitly allow `$exists`, `$eq`, `$ne` and `$elemMatch` operators here as they can't be misused this way
          const operator = Object.keys(data[prop.name]).some(
            f => Utils.isOperator(f) && !['$exists', '$ne', '$eq', '$elemMatch'].includes(f),
          );
          if (operator) {
            throw ValidationError.cannotUseOperatorsInsideEmbeddables(meta.class, prop.name, data);
          }
          if (prop.object && where) {
            const inline = (payload, sub, path) => {
              if (sub.kind === ReferenceKind.EMBEDDED && Utils.isObject(payload[sub.embedded[1]])) {
                return Object.keys(payload[sub.embedded[1]]).forEach(kkk => {
                  if (!sub.embeddedProps[kkk]) {
                    throw ValidationError.invalidEmbeddableQuery(meta.class, kkk, sub.type);
                  }
                  inline(payload[sub.embedded[1]], sub.embeddedProps[kkk], [...path, sub.fieldNames[0]]);
                });
              }
              data[`${path.join('.')}.${sub.fieldNames[0]}`] = payload[sub.embedded[1]];
            };
            const parentPropName = kk.substring(0, kk.indexOf('.'));
            // we might be using some native JSON operator, e.g. with mongodb's `$geoWithin` or `$exists`
            if (props[kk]) {
              /* v8 ignore next */
              inline(data[prop.name], props[kk] || props[parentPropName], [prop.fieldNames[0]]);
            } else if (props[parentPropName]) {
              data[`${prop.fieldNames[0]}.${kk}`] = data[prop.name][kk];
            } else {
              unknownProp = true;
            }
          } else if (props[kk]) {
            data[props[kk].fieldNames[0]] = data[prop.name][props[kk].embedded[1]];
          } else {
            throw ValidationError.invalidEmbeddableQuery(meta.class, kk, prop.type);
          }
        });
        if (!unknownProp) {
          delete data[prop.name];
        }
      }
    });
  }
  getPrimaryKeyFields(meta) {
    return meta.getPrimaryProps().flatMap(pk => pk.fieldNames);
  }
  createReplicas(cb) {
    const replicas = this.config.get('replicas', []);
    const ret = [];
    const props = [
      'dbName',
      'clientUrl',
      'host',
      'port',
      'user',
      'password',
      'multipleStatements',
      'pool',
      'name',
      'driverOptions',
    ];
    for (const conf of replicas) {
      const replicaConfig = Utils.copy(conf);
      for (const prop of props) {
        if (conf[prop]) {
          continue;
        }
        // do not copy options that can be inferred from explicitly provided `clientUrl`
        if (conf.clientUrl && ['clientUrl', 'host', 'port', 'user', 'password'].includes(prop)) {
          continue;
        }
        if (conf.clientUrl && prop === 'dbName' && new URL(conf.clientUrl).pathname) {
          continue;
        }
        replicaConfig[prop] = this.config.get(prop);
      }
      ret.push(cb(replicaConfig));
    }
    return ret;
  }
  /** Acquires a pessimistic lock on the given entity. */
  async lockPessimistic(entity, options) {
    throw new Error(`Pessimistic locks are not supported by ${this.constructor.name} driver`);
  }
  /**
   * @inheritDoc
   */
  convertException(exception) {
    if (exception instanceof DriverException) {
      return exception;
    }
    return this.platform.getExceptionConverter().convertException(exception);
  }
  rethrow(promise) {
    return promise.catch(e => {
      throw this.convertException(e);
    });
  }
  /**
   * @internal
   */
  getTableName(meta, options, quote = true) {
    const schema = this.getSchemaName(meta, options);
    const tableName =
      schema && schema !== this.platform.getDefaultSchemaName() ? `${schema}.${meta.tableName}` : meta.tableName;
    if (quote) {
      return this.platform.quoteIdentifier(tableName);
    }
    return tableName;
  }
  /**
   * @internal
   */
  getSchemaName(meta, options) {
    if (meta?.schema && meta.schema !== '*') {
      return meta.schema;
    }
    if (options?.schema === '*') {
      return this.config.get('schema');
    }
    const schemaName = meta?.schema === '*' ? this.config.getSchema() : meta?.schema;
    return options?.schema ?? options?.parentSchema ?? schemaName ?? this.config.getSchema();
  }
  /** @internal */
  getORMClass() {
    return MikroORM;
  }
}
