// Sequelize Model shim → D1
// Implements the Sequelize Model API subset used by Payment Kit

import { buildWhereClause } from './operators';
import { helperToSQL } from './helpers';
import { DataTypes } from './datatypes';
import { recordQuery, recordBatch } from './timing';

// Global hooks registry — keyed by class reference, avoids static property inheritance issues
const _hooksRegistry = new Map<any, Map<string, Map<string, Function>>>();

// Global D1 database reference, set by worker.ts at startup
let _db: any = null;
export function setDB(db: any) {
  _db = db;
  // PRAGMA table_info results are stable within a CF Workers isolate —
  // schema only changes during migration deploys, so cache survives across requests.
}
export function getDB(): any {
  if (!_db) throw new Error('D1 database not initialized. Call setDB() first.');
  return _db;
}

// Cache of actual D1 table columns (tableName → Set<columnName>).
// Prevents "no such column" errors when Sequelize model defines columns
// that don't exist in D1 (e.g. migration not yet applied).
const _d1ColumnCache = new Map<string, Set<string>>();

// --- Isolate-level query cache for rarely-changing tables ---
// PaymentCurrency and PaymentMethod are queried on nearly every endpoint but
// only change via admin actions. Caching them per-isolate with TTL eliminates
// ~100ms D1 RTT per cached query.
const CACHEABLE_TABLES = new Set(['payment_currencies', 'payment_methods', 'products', 'prices']);
const ISOLATE_CACHE_TTL_MS = 60_000; // 60 seconds
const _isolateCache = new Map<string, { data: any; expiresAt: number }>();

function getIsolateCacheKey(tableName: string, method: string, options: any): string | null {
  if (!CACHEABLE_TABLES.has(tableName)) return null;
  // Only cache simple findAll/findOne — not writes, not complex joins
  if (method !== 'findAll' && method !== 'findOne' && method !== 'findByPk') return null;
  try {
    // Stable key from table + where + attributes + order (exclude include — handled separately)
    const w = options?.where ? JSON.stringify(options.where) : '';
    const a = options?.attributes ? JSON.stringify(options.attributes) : '';
    const l = options?.limit ?? '';
    return `${tableName}:${method}:${w}:${a}:${l}`;
  } catch {
    return null;
  }
}

/** Invalidate isolate cache for a table (call after create/update/destroy on cacheable tables). */
export function invalidateIsolateCache(tableName?: string): void {
  if (!tableName) { _isolateCache.clear(); return; }
  for (const key of _isolateCache.keys()) {
    if (key.startsWith(`${tableName}:`)) _isolateCache.delete(key);
  }
}

async function getD1Columns(tableName: string): Promise<Set<string>> {
  const cached = _d1ColumnCache.get(tableName);
  if (cached) return cached;
  try {
    const result = await getDB().prepare(`PRAGMA table_info("${tableName}")`).all();
    const cols = new Set<string>((result.results || []).map((r: any) => r.name));
    if (cols.size > 0) {
      _d1ColumnCache.set(tableName, cols);
    }
    return cols;
  } catch {
    // If PRAGMA fails (table doesn't exist), return empty set — caller proceeds with all attrs
    return new Set<string>();
  }
}

// Association info stored during associate() calls
interface AssociationInfo {
  type: 'hasOne' | 'hasMany' | 'belongsTo' | 'belongsToMany';
  target: any; // target Model class
  foreignKey?: string;
  sourceKey?: string;
  as?: string;
  through?: any;
  otherKey?: string;
}

// Model registry — maps Model class to table name + attributes + associations
export const modelRegistry = new Map<any, { tableName: string; attributes: Record<string, any>; associations: AssociationInfo[] }>();

// Shared models registry — all Sequelize instances share the same models map.
// This is critical because some model files import `sequelize` from '../sequelize'
// (creating a different Sequelize instance than the one passed to initialize()),
// then reference `sequelize.models.SomeModel` for include queries.
export const sharedModels: Record<string, any> = {};

// Global Sequelize instance reference (set by Model.init)
// Needed so instance methods can call this.sequelize.models, this.sequelize.query
let _sequelizeInstance: any = null;

export class Model {
  [key: string]: any;

  dataValues: any = {};
  _previousDataValues: any = {};

  constructor(data?: any) {
    if (data) {
      Object.assign(this, data);
      this.dataValues = { ...data };
      this._previousDataValues = { ...data };
    }
  }

  // --- Schema registration (called by each model file's initialize()) ---

  static init(attributes: Record<string, any>, options: any) {
    const tableName = options.tableName || options.modelName || this.name.toLowerCase() + 's';
    modelRegistry.set(this, {
      tableName,
      attributes,
      associations: [],
    });
    // Store on the class for easy access
    (this as any)._tableName = tableName;
    (this as any)._attributes = attributes;

    // Store scopes
    if (options.scopes) this._scopes = options.scopes;
    if (options.defaultScope) this._defaultScope = options.defaultScope;

    // Register hooks from init options (e.g., Invoice has afterCreate/afterUpdate in options.hooks)
    if (options.hooks) {
      for (const [hookType, fn] of Object.entries(options.hooks)) {
        if (typeof fn === 'function') {
          this.addHook(hookType, `init_${hookType}`, fn as Function);
        }
      }
    }

    // Store reference to sequelize instance passed in options
    if (options.sequelize) {
      _sequelizeInstance = options.sequelize;
      // Register this model in sequelize.models (which points to sharedModels)
      if (_sequelizeInstance.models) {
        _sequelizeInstance.models[options.modelName || this.name] = this;
      }
      // Also register directly in sharedModels as safety net
      sharedModels[options.modelName || this.name] = this;
      // Set class-level sequelize reference
      (this as any)._sequelizeInstance = options.sequelize;
    }

    // Set class-level sequelize property (used by instance methods as this.sequelize)
    Object.defineProperty(this, 'sequelize', {
      get: () => _sequelizeInstance || { query: d1Query, models: sharedModels },
      configurable: true,
    });

    return this;
  }

  static associate(_models: any) {
    // Subclasses override this to call hasOne/hasMany/belongsTo
  }

  // Sequelize association methods (called during associate())
  static hasOne(target: any, options?: any) {
    registerAssociation(this, 'hasOne', target, options);
  }
  static hasMany(target: any, options?: any) {
    registerAssociation(this, 'hasMany', target, options);
  }
  static belongsTo(target: any, options?: any) {
    registerAssociation(this, 'belongsTo', target, options);
  }
  static belongsToMany(target: any, options?: any) {
    registerAssociation(this, 'belongsToMany', target, options);
  }

  // Scopes — Sequelize Model.scope('name') returns a scoped model
  // For simplicity, we return `this` (all columns) since D1 doesn't need column filtering
  static _scopes: Record<string, any> = {};
  static _defaultScope: any = {};

  static scope(_scopeName?: string | string[]): typeof Model {
    // Return this class itself — scope filtering is a no-op in CF Workers
    // The query will return all columns regardless
    return this;
  }

  static unscoped(): typeof Model {
    return this;
  }

  // Hook system — uses a global registry keyed by class reference to avoid
  // prototype chain sharing issues with static properties
  static _hooks: Map<string, Map<string, Function>> = new Map();

  static addHook(hookType: string, name: string, fn: Function) {
    const classHooks = _hooksRegistry.get(this) || new Map();
    if (!classHooks.has(hookType)) classHooks.set(hookType, new Map());
    classHooks.get(hookType)!.set(name, fn);
    _hooksRegistry.set(this, classHooks);
  }

  static removeHook(hookType: string, name: string) {
    _hooksRegistry.get(this)?.get(hookType)?.delete(name);
  }

  // Stack of async trackers for nested _runHooks calls.
  // Hooks often call createEvent(...).catch() which is fire-and-forget — the async
  // work (Event.create → events.emit) would complete after the request returns.
  // We track Model.create() calls made during hook execution and await them,
  // so events.emit fires within the request lifecycle.
  static _hookAsyncStack: Promise<any>[][] = [];

  static async _runHooks(hookType: string, ...args: any[]) {
    const classHooks = _hooksRegistry.get(this);
    if (!classHooks) return;
    const hooks = classHooks.get(hookType);
    if (!hooks || hooks.size === 0) return;

    // Push a tracking frame — any Model.create/update during this hook
    // will push its promise here (see create/update methods).
    Model._hookAsyncStack.push([]);

    try {
      for (const [name, fn] of hooks) {
        try {
          const result = fn(...args);
          if (result && typeof result.then === 'function') {
            await result;
          }
        } catch (err: any) {
          console.error(`[sequelize-d1] Hook ${hookType}:${name} error:`, err?.message || err);
        }
      }

      // Await all floating async work (e.g. createEvent → Event.create) started
      // during this hook's synchronous execution. When these resolve, the
      // continuations (events.emit, addToBatch, etc.) run before we return.
      const tracked = Model._hookAsyncStack[Model._hookAsyncStack.length - 1];
      if (tracked && tracked.length > 0) {
        await Promise.allSettled(tracked);
      }
    } finally {
      Model._hookAsyncStack.pop();
    }
  }

  // --- Query methods ---

  static get tableName(): string {
    return (this as any)._tableName || this.name.toLowerCase() + 's';
  }

  // Instance-level 'sequelize' property (for this.sequelize.models, this.sequelize.query)
  get sequelize(): any {
    return _sequelizeInstance || { query: d1Query, models: sharedModels };
  }

  static async findAll(options?: any): Promise<any[]> {
    // Check isolate cache for rarely-changing tables (skip if includes — those need fresh resolution)
    const cacheKey = !options?.include?.length ? getIsolateCacheKey(this.tableName, 'findAll', options) : null;
    if (cacheKey) {
      const cached = _isolateCache.get(cacheKey);
      if (cached && Date.now() < cached.expiresAt) {
        return cached.data.map((r: any) => createInstance(this, { ...r }));
      }
    }

    const { sql, values } = buildSelectSQL(this.tableName, options, (this as any)._attributes);
    const _t0 = performance.now();
    const result = await getDB().prepare(sql).bind(...values).all();
    recordQuery(result.meta, performance.now() - _t0);
    const rows = (result.results || []).map((r: any) => createInstance(this, r));

    // Handle include (eager loading via separate queries)
    if (options?.include?.length) {
      await resolveIncludes(rows, options.include, this);
    }

    // Apply attribute exclusions (e.g. { attributes: { exclude: ['data', 'request'] } })
    const excl = options?.attributes?.exclude;
    if (Array.isArray(excl) && excl.length > 0) {
      for (const row of rows) {
        for (const field of excl) {
          delete row[field];
          delete row.dataValues[field];
        }
      }
    }

    // Store in isolate cache (raw data, not instances — instances get fresh dataValues on cache hit)
    if (cacheKey) {
      _isolateCache.set(cacheKey, {
        data: rows.map((r: any) => ({ ...r.dataValues })),
        expiresAt: Date.now() + ISOLATE_CACHE_TTL_MS,
      });
    }

    return rows;
  }

  static async findOne(options?: any): Promise<any | null> {
    const results = await this.findAll({ ...options, limit: 1 });
    return results[0] || null;
  }

  static async findByPk(id: string | number, options?: any): Promise<any | null> {
    if (!id) return null;
    return this.findOne({ ...options, where: { ...(options?.where || {}), id } });
  }

  /**
   * Sequelize-compatible `findOrCreate`: find a row matching `where`, otherwise
   * insert a new one using `defaults` merged over `where`. Returns the
   * Sequelize tuple `[instance, wasCreated]`.
   *
   * D1 doesn't expose row-level locks, so this is racy at the storage layer —
   * two concurrent calls with the same `where` can both miss and both insert.
   * For our IAP / customer flows that's acceptable because primary-key
   * collisions (or unique-index constraints) catch the second writer, and the
   * IAP entry path already de-dupes by purchase token upstream. Mirrors the
   * single-node Sequelize behavior of "not strictly atomic" under SQLite.
   */
  static async findOrCreate(options: { where: any; defaults?: any }): Promise<[any, boolean]> {
    const existing = await this.findOne({ where: options.where });
    if (existing) return [existing, false];
    const created = await this.create({ ...(options.where || {}), ...(options.defaults || {}) });
    return [created, true];
  }

  static async findAndCountAll(options?: any): Promise<{ rows: any[]; count: number }> {
    // Batch SELECT + COUNT into a single D1 round-trip
    const { sql: selectSql, values: selectValues } = buildSelectSQL(this.tableName, options, (this as any)._attributes);
    const countWhere = buildWhereClause(options?.where || {});
    const countWhereClause = countWhere.sql ? ` WHERE ${countWhere.sql}` : '';
    const countSql = `SELECT COUNT(*) as count FROM "${this.tableName}"${countWhereClause}`;

    const db = getDB();
    const _t0 = performance.now();
    const [countResult, selectResult] = await db.batch([
      db.prepare(countSql).bind(...countWhere.values),
      db.prepare(selectSql).bind(...selectValues),
    ]);
    recordBatch([countResult, selectResult], performance.now() - _t0);
    const count = (countResult as any).results?.[0]?.count || 0;
    const rows = ((selectResult as any).results || []).map((r: any) => createInstance(this, r));

    // Handle include (eager loading via separate queries)
    if (options?.include?.length) {
      await resolveIncludes(rows, options.include, this);
    }

    // Apply attribute exclusions
    const excl = options?.attributes?.exclude;
    if (Array.isArray(excl) && excl.length > 0) {
      for (const row of rows) {
        for (const field of excl) {
          delete row[field];
          delete row.dataValues[field];
        }
      }
    }

    return { rows, count };
  }

  static create(data: any, _options?: any): Promise<any> {
    const promise = this._createImpl(data, _options);
    // Track in parent hook's async frame — captures fire-and-forget create() calls
    // (e.g. createEvent → Event.create) so _runHooks can await them.
    // This ensures events.emit fires before the parent create() returns,
    // which is critical for business event chains (credit consume, auto-recharge, etc.)
    const stack = Model._hookAsyncStack;
    if (stack && stack.length > 0) {
      stack[stack.length - 1].push(promise);
    }
    return promise;
  }

  static async _createImpl(data: any, _options?: any): Promise<any> {
    if (CACHEABLE_TABLES.has(this.tableName)) invalidateIsolateCache(this.tableName);
    const attrs = Object.keys((this as any)._attributes || {});
    const fields: string[] = [];
    const placeholders: string[] = [];
    const values: any[] = [];

    // Filter out columns that don't exist in D1 to avoid "no such column" errors
    const d1Cols = await getD1Columns(this.tableName);

    for (const key of attrs) {
      // Skip columns not present in D1 table (migration not applied)
      if (d1Cols.size > 0 && !d1Cols.has(key)) continue;

      const attrDef = (this as any)._attributes[key];
      let val = data[key];

      // Apply defaultValue if not provided
      if (val === undefined && attrDef?.defaultValue !== undefined) {
        val = typeof attrDef.defaultValue === 'function' ? attrDef.defaultValue() : attrDef.defaultValue;
      }

      // CF Workers: DataTypes.NOW is a static placeholder captured at module load (Date.now()=0).
      // Replace with actual request-time timestamp for DATE fields.
      if (val === 'CF_NOW_PLACEHOLDER' || (val instanceof Date && val.getTime() === 0)) {
        val = new Date().toISOString();
      }

      if (val !== undefined) {
        fields.push(`"${key}"`);
        placeholders.push('?');
        values.push(serializeValue(val, attrDef, key));
      }
    }

    if (fields.length === 0) return createInstance(this, data);

    const db = getDB();
    const insertSql = `INSERT INTO "${this.tableName}" (${fields.join(', ')}) VALUES (${placeholders.join(', ')})`;

    // Batch INSERT + SELECT into one D1 round-trip when we know the ID
    let created;
    if (data.id) {
      const selectSql = `SELECT * FROM "${this.tableName}" WHERE "id" = ?`;
      const _t0 = performance.now();
      const batchResult = await db.batch([
        db.prepare(insertSql).bind(...values),
        db.prepare(selectSql).bind(data.id),
      ]);
      recordBatch(batchResult, performance.now() - _t0);
      const row = batchResult[1].results?.[0];
      created = row ? createInstance(this, row) : createInstance(this, data);
    } else {
      // Batch INSERT + SELECT by last_insert_rowid() into one D1 round-trip
      const _t0 = performance.now();
      const batchResult = await db.batch([
        db.prepare(insertSql).bind(...values),
        db.prepare(`SELECT * FROM "${this.tableName}" WHERE rowid = last_insert_rowid()`),
      ]);
      recordBatch(batchResult, performance.now() - _t0);
      const row = (batchResult[1] as any).results?.[0];
      created = row ? createInstance(this, row) : createInstance(this, data);
    }

    // Fire afterCreate hooks (must run for ALL code paths)
    await this._runHooks('afterCreate', created, _options || {});
    return created;
  }

  static async update(data: any, options: any): Promise<[number]> {
    if (CACHEABLE_TABLES.has(this.tableName)) invalidateIsolateCache(this.tableName);
    const setClauses: string[] = [];
    const values: any[] = [];
    const attrs = (this as any)._attributes || {};

    // Auto-set updated_at if the model has it
    if (attrs.updated_at && data.updated_at === undefined) {
      data.updated_at = new Date().toISOString();
    }

    // Filter out columns that don't exist in D1 to avoid "no such column" errors
    const d1Cols = await getD1Columns(this.tableName);

    for (const [key, value] of Object.entries(data)) {
      // Skip columns not present in D1 table
      if (d1Cols.size > 0 && !d1Cols.has(key)) continue;

      setClauses.push(`"${key}" = ?`);
      values.push(serializeValue(value, attrs[key], key));
    }

    const where = buildWhereClause(options?.where || {});
    values.push(...where.values);

    const whereClause = where.sql ? ` WHERE ${where.sql}` : '';
    const sql = `UPDATE "${this.tableName}" SET ${setClauses.join(', ')}${whereClause}`;
    const _t0 = performance.now();
    const result = await getDB().prepare(sql).bind(...values).run();
    recordQuery(result.meta, performance.now() - _t0);
    const changes = result.meta?.changes ?? result.changes ?? 0;

    return [changes];
  }

  static async destroy(options: any): Promise<number> {
    if (CACHEABLE_TABLES.has(this.tableName)) invalidateIsolateCache(this.tableName);
    const where = buildWhereClause(options?.where || {});
    const whereClause = where.sql ? ` WHERE ${where.sql}` : '';
    const sql = `DELETE FROM "${this.tableName}"${whereClause}`;
    const _t0 = performance.now();
    const result = await getDB().prepare(sql).bind(...where.values).run();
    recordQuery(result.meta, performance.now() - _t0);
    return result.meta?.changes ?? result.changes ?? 0;
  }

  static async count(options?: any): Promise<number> {
    const where = buildWhereClause(options?.where || {});
    const whereClause = where.sql ? ` WHERE ${where.sql}` : '';
    const sql = `SELECT COUNT(*) as count FROM "${this.tableName}"${whereClause}`;
    const _t0 = performance.now();
    const result = await getDB().prepare(sql).bind(...where.values).first();
    recordQuery(null, performance.now() - _t0); // .first() has no meta
    return (result as any)?.count || 0;
  }

  // Static increment — e.g. Price.increment({ quantity_sold: 1 }, { where: { id } })
  static async increment(fields: string | Record<string, number>, options?: any): Promise<any> {
    const setClauses: string[] = [];
    const values: any[] = [];

    if (typeof fields === 'string') {
      const by = options?.by ?? 1;
      setClauses.push(`"${fields}" = "${fields}" + ?`);
      values.push(by);
    } else {
      for (const [field, amount] of Object.entries(fields)) {
        setClauses.push(`"${field}" = "${field}" + ?`);
        values.push(amount);
      }
    }

    const where = buildWhereClause(options?.where || {});
    values.push(...where.values);
    const whereClause = where.sql ? ` WHERE ${where.sql}` : '';
    const sql = `UPDATE "${this.tableName}" SET ${setClauses.join(', ')}${whereClause}`;
    const _t0 = performance.now();
    const result = await getDB().prepare(sql).bind(...values).run();
    recordQuery(result.meta, performance.now() - _t0);
    return this;
  }

  // Static decrement
  static async decrement(fields: string | Record<string, number>, options?: any): Promise<any> {
    const setClauses: string[] = [];
    const values: any[] = [];

    if (typeof fields === 'string') {
      const by = options?.by ?? 1;
      setClauses.push(`"${fields}" = "${fields}" - ?`);
      values.push(by);
    } else {
      for (const [field, amount] of Object.entries(fields)) {
        setClauses.push(`"${field}" = "${field}" - ?`);
        values.push(amount);
      }
    }

    const where = buildWhereClause(options?.where || {});
    values.push(...where.values);
    const whereClause = where.sql ? ` WHERE ${where.sql}` : '';
    const sql = `UPDATE "${this.tableName}" SET ${setClauses.join(', ')}${whereClause}`;
    const _t0d = performance.now();
    const resultD = await getDB().prepare(sql).bind(...values).run();
    recordQuery(resultD.meta, performance.now() - _t0d);
    return this;
  }

  static async bulkCreate(records: any[], options?: any): Promise<any[]> {
    if (records.length === 0) return [];
    const results: any[] = [];
    // D1 supports batch but for simplicity do sequential
    // For production: use db.batch([...stmts])
    for (const data of records) {
      results.push(await this.create(data, options));
    }
    return results;
  }

  static async max(field: string, options?: any): Promise<any> {
    const where = buildWhereClause(options?.where || {});
    const whereClause = where.sql ? ` WHERE ${where.sql}` : '';
    const sql = `SELECT MAX("${field}") as val FROM "${this.tableName}"${whereClause}`;
    const result = await getDB().prepare(sql).bind(...where.values).first();
    return (result as any)?.val;
  }

  static async sum(field: string, options?: any): Promise<number> {
    const where = buildWhereClause(options?.where || {});
    const whereClause = where.sql ? ` WHERE ${where.sql}` : '';
    const sql = `SELECT SUM("${field}") as val FROM "${this.tableName}"${whereClause}`;
    const result = await getDB().prepare(sql).bind(...where.values).first();
    return (result as any)?.val || 0;
  }

  // --- Instance methods ---

  async update(data: any, _options?: any): Promise<this> {
    const ModelClass = this.constructor as typeof Model;
    // Track previous values for hooks
    this._previousDataValues = { ...this.dataValues };
    await ModelClass.update(data, { where: { id: this.id } });
    Object.assign(this, data);
    this.dataValues = { ...this.dataValues, ...data };

    // Fire afterUpdate hooks directly on this instance
    // Sequelize passes options.fields (list of updated field names) to hooks
    const hookOptions = { ...(_options || {}), fields: Object.keys(data) };
    try {
      await (ModelClass as any)._runHooks('afterUpdate', this, hookOptions);
    } catch (e: any) {
      console.error('[sequelize-d1] afterUpdate hook error:', e?.message);
    }
    return this;
  }

  async destroy(_options?: any): Promise<void> {
    const ModelClass = this.constructor as typeof Model;
    await ModelClass.destroy({ where: { id: this.id } });
  }

  async reload(_options?: any): Promise<this> {
    const ModelClass = this.constructor as typeof Model;
    const fresh = await ModelClass.findByPk(this.id);
    if (fresh) Object.assign(this, fresh);
    return this;
  }

  async save(_options?: any): Promise<this> {
    const ModelClass = this.constructor as typeof Model;
    const attrs = (ModelClass as any)._attributes || {};
    const data: any = {};
    for (const key of Object.keys(attrs)) {
      if ((this as any)[key] !== undefined) {
        data[key] = (this as any)[key];
      }
    }
    if (this.id) {
      await ModelClass.update(data, { where: { id: this.id } });
    } else {
      const created = await ModelClass.create(data);
      Object.assign(this, created);
    }
    return this;
  }

  async increment(field: string | Record<string, number>, options?: any): Promise<this> {
    const ModelClass = this.constructor as typeof Model;
    const by = options?.by ?? 1;
    if (typeof field === 'string') {
      const sql = `UPDATE "${(ModelClass as any).tableName}" SET "${field}" = "${field}" + ? WHERE "id" = ?`;
      await getDB().prepare(sql).bind(by, this.id).run();
    } else {
      for (const [f, amount] of Object.entries(field)) {
        const sql = `UPDATE "${(ModelClass as any).tableName}" SET "${f}" = "${f}" + ? WHERE "id" = ?`;
        await getDB().prepare(sql).bind(amount, this.id).run();
      }
    }
    return this.reload();
  }

  async decrement(field: string | Record<string, number>, options?: any): Promise<this> {
    const ModelClass = this.constructor as typeof Model;
    const by = options?.by ?? 1;
    if (typeof field === 'string') {
      const sql = `UPDATE "${(ModelClass as any).tableName}" SET "${field}" = "${field}" - ? WHERE "id" = ?`;
      await getDB().prepare(sql).bind(by, this.id).run();
    } else {
      for (const [f, amount] of Object.entries(field)) {
        const sql = `UPDATE "${(ModelClass as any).tableName}" SET "${f}" = "${f}" - ? WHERE "id" = ?`;
        await getDB().prepare(sql).bind(amount, this.id).run();
      }
    }
    return this.reload();
  }

  toJSON(): any {
    const data: any = { ...this.dataValues };
    // Overlay with direct instance properties — mirrors real Sequelize behavior where
    // property assignments (e.g. doc.line_items = await Price.expand(...)) sync to dataValues
    // via setters. Our shim lacks setters, so toJSON must prefer instance properties.
    for (const key of Object.keys(this)) {
      if (key === 'dataValues' || key === '_previousDataValues' || key === '_changed') continue;
      const value = (this as any)[key];
      if (typeof value === 'function') continue;
      data[key] = value;
    }
    return data;
  }

  get(field?: string): any {
    if (field !== undefined) return (this as any)[field];
    return this.toJSON();
  }

  getDataValue(field: string): any {
    return (this as any)[field];
  }

  setDataValue(field: string, value: any): void {
    (this as any)[field] = value;
  }

  // Changed flag — simple stub for compatibility
  changed(field?: string): boolean | string[] {
    if (!this._previousDataValues) return false;
    if (field) {
      return this._previousDataValues[field] !== this.dataValues[field];
    }
    // Return list of changed fields
    const changed: string[] = [];
    for (const key of Object.keys(this.dataValues)) {
      if (this._previousDataValues[key] !== this.dataValues[key]) {
        changed.push(key);
      }
    }
    return changed.length > 0 ? changed : false;
  }

  previous(field?: string): any {
    if (!this._previousDataValues) return undefined;
    if (field) return this._previousDataValues[field];
    return this._previousDataValues;
  }
}

// --- Internal helpers ---

function createInstance(ModelClass: any, data: any): any {
  const instance = new ModelClass(deserializeRow(data, ModelClass._attributes));
  return instance;
}

function serializeValue(value: any, attrDef?: any, fieldName?: string): any {
  if (value === null || value === undefined) return null;
  if (value instanceof Date) return value.toISOString();
  if (typeof value === 'boolean') return value ? 1 : 0;
  if (typeof value === 'object') return JSON.stringify(value);
  // Prevent D1 from storing numbers as floats in TEXT columns
  // Two checks: 1) column is TEXT/VARCHAR type, 2) field name matches amount patterns
  if (typeof value === 'number') {
    const isText = attrDef?.type === DataTypes.TEXT ||
      (typeof attrDef?.type === 'object' && attrDef?.type?.type === 'TEXT');
    const isAmount = fieldName ? isAmountField(fieldName) : false;
    if (isText || isAmount) {
      return String(Number.isInteger(value) ? value : value);
    }
  }
  return value;
}

// JSON fields by DataType — used for deserialization
const JSON_TYPES = new Set(['JSON', 'JSONB']);

function isJsonField(attrDef: any, fieldName: string): boolean {
  if (!attrDef) return false;
  const type = attrDef.type;
  if (!type) return false;
  if (typeof type === 'string' && JSON_TYPES.has(type)) return true;
  if (type === DataTypes.JSON || type === DataTypes.JSONB) return true;
  if (typeof type === 'object') {
    if (type.type === 'TEXT' && (type === DataTypes.JSON || type === DataTypes.JSONB)) return true;
    // Check by type name
    if (JSON_TYPES.has(type.type)) return false; // TEXT by itself isn't JSON
  }
  // Heuristic fallback for common JSON column names
  const JSON_FIELD_PATTERNS = /metadata|images|features|vendor_config|cross_sell|currency_options|overdraft_protection|credit_config|token_balance|line_items|price_data|billing_address|customer_details|payment_details|setup_details|nft_config|vc_config|customer_dids|preference|recharge_config|vault_config|service_actions|staking_props|price_quote/;
  return JSON_FIELD_PATTERNS.test(fieldName);
}

// Amount/currency fields that must remain as strings for BN.js compatibility
const AMOUNT_FIELD_PATTERNS = /^(amount|total|subtotal|unit_amount|balance|share|minimum_payment_amount|maximum_payment_amount|remaining_amount|consumed_amount|credited_amount|max_pending_amount|min_stake_amount|billing_threshold_amount|amount_due|amount_paid|amount_remaining|amount_capturable|amount_received|amount_refunded|amount_discount|amount_shipping|amount_tax|base_amount)/;
function isAmountField(fieldName: string): boolean {
  return AMOUNT_FIELD_PATTERNS.test(fieldName);
}

function isBooleanField(attrDef: any, fieldName: string): boolean {
  if (!attrDef) return false;
  if (attrDef.type === DataTypes.BOOLEAN) return true;
  // Heuristic: is_ prefix or known boolean fields
  return fieldName.startsWith('is_') || ['active', 'livemode', 'locked', 'confirmed', 'usage_billed', 'active_now', 'collect_phone', 'collect_address'].includes(fieldName);
}

function deserializeRow(row: any, attributes?: Record<string, any>): any {
  if (!row) return row;
  const result: any = {};
  for (const [key, value] of Object.entries(row)) {
    const attrDef = attributes?.[key];

    if (isJsonField(attrDef, key)) {
      try {
        result[key] = typeof value === 'string' ? JSON.parse(value as string) : value;
      } catch {
        result[key] = value;
      }
    } else if (isBooleanField(attrDef, key)) {
      result[key] = value === 1 || value === true;
    } else if (isAmountField(key) && value !== null && value !== undefined) {
      // Amount fields must be strings for BN.js compatibility
      // D1 may return numbers (50 or 50.0) or strings ("50.0")
      let str = String(value);
      // Remove trailing .0 (D1 float artifact)
      if (/^\d+\.0$/.test(str)) str = str.slice(0, -2);
      // Also handle "50.00" etc
      if (/^\d+\.0+$/.test(str)) str = str.replace(/\.0+$/, '');
      result[key] = str;
    } else if (typeof value === 'number' && !Number.isInteger(value) && value === Math.floor(value)) {
      // D1 returns integer values as floats (e.g., 50.0 instead of 50)
      result[key] = Math.floor(value);
    } else {
      result[key] = value;
    }
  }
  return result;
}

// Convert dotted field path to SQLite json_extract
function fieldToSQL(field: string): string {
  if (field.includes('.')) {
    const parts = field.split('.');
    const column = parts[0];
    const jsonPath = '$.' + parts.slice(1).join('.');
    return `json_extract("${column}", '${jsonPath}')`;
  }
  return `"${field}"`;
}

function buildSelectSQL(
  tableName: string,
  options?: any,
  _attributes?: Record<string, any>,
): { sql: string; values: any[] } {
  const parts: string[] = [];
  const values: any[] = [];

  // SELECT — handle attributes in various forms
  const attrClause = buildAttributesClause(options?.attributes);
  parts.push(`SELECT ${attrClause} FROM "${tableName}"`);

  // WHERE
  const where = buildWhereClause(options?.where || {});
  if (where.sql) {
    parts.push(`WHERE ${where.sql}`);
    values.push(...where.values);
  }

  // GROUP BY
  if (options?.group) {
    const groups = Array.isArray(options.group) ? options.group : [options.group];
    parts.push(`GROUP BY ${groups.map((g: string) => `"${g}"`).join(', ')}`);
  }

  // HAVING
  if (options?.having) {
    const having = buildWhereClause(options.having);
    if (having.sql) {
      parts.push(`HAVING ${having.sql}`);
      values.push(...having.values);
    }
  }

  // ORDER BY
  if (options?.order) {
    const orders = (options.order as any[]).map((o: any) => {
      if (Array.isArray(o)) {
        const col = typeof o[0] === 'object' ? helperToSQL(o[0]) : fieldToSQL(o[0]);
        const dir = (o[1] || 'ASC').toUpperCase();
        return `${col} ${dir}`;
      }
      if (typeof o === 'object') return helperToSQL(o);
      return `"${o}"`;
    });
    parts.push(`ORDER BY ${orders.join(', ')}`);
  }

  // LIMIT / OFFSET
  if (options?.limit != null) {
    parts.push('LIMIT ?');
    values.push(Number(options.limit));
  }
  if (options?.offset != null) {
    parts.push('OFFSET ?');
    values.push(Number(options.offset));
  }

  return { sql: parts.join(' '), values };
}

function buildAttributesClause(attributes: any): string {
  if (!attributes) return '*';

  // Array form: ['id', 'name'] or [[fn('COUNT', col('id')), 'count']]
  if (Array.isArray(attributes)) {
    if (attributes.length === 0) return '*';
    const cols = attributes.map((a: any) => {
      if (Array.isArray(a)) {
        // [expr, alias]
        return `${helperToSQL(a[0])} AS "${a[1]}"`;
      }
      if (typeof a === 'object') return helperToSQL(a);
      return fieldToSQL(a);
    });
    return cols.join(', ');
  }

  // Object form: { exclude: ['col1'] } or { include: [[expr, alias]] }
  if (typeof attributes === 'object') {
    if (attributes.include) {
      // Add extra computed columns to SELECT *
      const extras = (attributes.include as any[]).map((a: any) => {
        if (Array.isArray(a)) return `${helperToSQL(a[0])} AS "${a[1]}"`;
        return helperToSQL(a);
      });
      return `*, ${extras.join(', ')}`;
    }
    // exclude: handled post-fetch (not SQL level for simplicity)
    return '*';
  }

  return '*';
}

// Apply attribute exclusions after fetch (for { exclude: [...] } form)
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function applyAttributeExclusions(data: any, attributes: any): any {
  if (!attributes || !attributes.exclude || !Array.isArray(attributes.exclude)) return data;
  const result = { ...data };
  for (const field of attributes.exclude) {
    delete result[field];
  }
  return result;
}

// --- Include resolution ---
// Handles hasOne (single object), hasMany (array), belongsTo (single object)

async function resolveIncludes(rows: any[], includes: any[], SourceModel: any) {
  if (!rows.length || !includes?.length) return;

  const sourceInfo = modelRegistry.get(SourceModel);

  // Resolve all includes in parallel — each include is an independent query
  // (e.g., Customer, Meter, Subscription includes can all run concurrently).
  // This reduces N sequential D1 round-trips to 1 parallel batch.
  await Promise.all(
    includes.filter(Boolean).map((inc) => {
      if (inc.association && !inc.model) {
        const assocInfo = sourceInfo?.associations.find((a: any) => a.as === inc.association);
        if (assocInfo) {
          return resolveOneInclude(rows, { ...inc, model: assocInfo.target, as: assocInfo.as, ...assocInfo }, SourceModel);
        }
        return Promise.resolve();
      }
      return resolveOneInclude(rows, inc, SourceModel);
    })
  );
}

// Batch IN-clause queries to avoid D1/SQLite bind parameter limits.
// D1 can fail with large IN lists (100+ values with additional WHERE params).
const IN_BATCH_SIZE = 100;

async function batchedInQuery(
  targetTable: string,
  keyColumn: string,
  values: any[],
  inc: any,
): Promise<any[]> {
  if (values.length === 0) return [];

  const db = getDB();
  const allResults: any[] = [];

  // Build extra WHERE/ORDER clauses once
  let extraWhereSql = '';
  const extraWhereValues: any[] = [];
  if (inc.where) {
    const incWhere = buildWhereClause(inc.where);
    if (incWhere.sql) {
      extraWhereSql = ` AND ${incWhere.sql}`;
      extraWhereValues.push(...incWhere.values);
    }
  }

  let orderSql = '';
  if (inc.order) {
    const orderClauses = (inc.order as any[]).map((o: any) => {
      if (Array.isArray(o)) return `"${o[0]}" ${o[1] || 'ASC'}`;
      return typeof o === 'object' ? helperToSQL(o) : `"${o}"`;
    });
    orderSql = ` ORDER BY ${orderClauses.join(', ')}`;
  }

  // Build prepared statements for all slices, then execute in a single db.batch() round-trip
  const stmts: any[] = [];
  for (let i = 0; i < values.length; i += IN_BATCH_SIZE) {
    const slice = values.slice(i, i + IN_BATCH_SIZE);
    const placeholders = slice.map(() => '?').join(', ');
    const sql = `SELECT * FROM "${targetTable}" WHERE "${keyColumn}" IN (${placeholders})${extraWhereSql}${orderSql}`;
    const bindValues = [...slice, ...extraWhereValues];
    stmts.push(db.prepare(sql).bind(...bindValues));
  }

  if (stmts.length === 1) {
    const _t0 = performance.now();
    const result = await stmts[0].all();
    recordQuery(result.meta, performance.now() - _t0);
    allResults.push(...(result.results || []));
  } else {
    const _t0 = performance.now();
    const results = await db.batch(stmts);
    recordBatch(results, performance.now() - _t0);
    for (const result of results) {
      allResults.push(...((result as any).results || []));
    }
  }

  // Apply limit after collecting all batches (if specified)
  if (inc.limit && allResults.length > inc.limit) {
    return allResults.slice(0, inc.limit);
  }

  return allResults;
}

async function resolveOneInclude(rows: any[], inc: any, SourceModel: any) {
  const targetModel = inc.model;
  if (!targetModel) return;

  const targetTable = targetModel._tableName || targetModel.tableName || targetModel.name.toLowerCase() + 's';
  const sourceInfo = modelRegistry.get(SourceModel);

  // Find the association definition to get correct FK direction
  const assocDef = sourceInfo?.associations.find(
    (a) => a.target === targetModel && (!inc.as || a.as === inc.as),
  );

  const assocType = assocDef?.type || 'hasOne';
  const as = inc.as || assocDef?.as || targetModel.name.toLowerCase();

  let sourceKey: string;
  let foreignKey: string;

  if (assocType === 'belongsTo') {
    // Source has the FK pointing to Target's PK
    // e.g. SubscriptionItem belongsTo Subscription: SubscriptionItem.subscription_id -> Subscription.id
    foreignKey = inc.foreignKey || assocDef?.foreignKey || `${as}_id`;
    sourceKey = inc.sourceKey || assocDef?.sourceKey || 'id';

    // Collect FK values from source rows
    const fkValues = [...new Set(rows.map((r) => r[foreignKey]).filter((v) => v != null))];
    if (fkValues.length === 0) {
      rows.forEach((r) => (r[as] = null));
      return;
    }

    // Batched IN query to avoid D1 bind parameter limits
    const relResults = await batchedInQuery(targetTable, sourceKey, fkValues, inc);
    const relMap = new Map<string, any>();
    for (const r of relResults) {
      const instance = createInstance(targetModel, r);
      // Resolve nested includes if any
      if (inc.include?.length) {
        await resolveIncludes([instance], inc.include, targetModel);
      }
      relMap.set(String((r as any)[sourceKey]), instance);
    }

    for (const row of rows) {
      const fkVal = row[foreignKey];
      row[as] = fkVal != null ? relMap.get(String(fkVal)) || null : null;
    }

  } else if (assocType === 'hasMany') {
    // Source's PK is referenced by Target's FK
    // e.g. Invoice hasMany InvoiceItem: InvoiceItem.invoice_id -> Invoice.id
    sourceKey = inc.sourceKey || assocDef?.sourceKey || 'id';
    foreignKey = inc.foreignKey || assocDef?.foreignKey || `${SourceModel.name.toLowerCase()}_id`;

    const sourceValues = [...new Set(rows.map((r) => r[sourceKey]).filter((v) => v != null))];
    if (sourceValues.length === 0) {
      rows.forEach((r) => (r[as] = []));
      return;
    }

    // Batched IN query to avoid D1 bind parameter limits
    const relResults = await batchedInQuery(targetTable, foreignKey, sourceValues, inc);

    // Group results by foreignKey value
    const relMap = new Map<string, any[]>();
    for (const r of relResults) {
      const fkVal = String((r as any)[foreignKey]);
      if (!relMap.has(fkVal)) relMap.set(fkVal, []);
      const instance = createInstance(targetModel, r);
      relMap.get(fkVal)!.push(instance);
    }

    // Resolve nested includes on all collected instances
    if (inc.include?.length) {
      const allInstances = Array.from(relMap.values()).flat();
      if (allInstances.length > 0) {
        await resolveIncludes(allInstances, inc.include, targetModel);
      }
    }

    for (const row of rows) {
      const srcVal = row[sourceKey];
      row[as] = srcVal != null ? relMap.get(String(srcVal)) || [] : [];
    }

  } else {
    // hasOne — Source's PK referenced by Target, but returns single result
    // OR: Source has a FK pointing to Target PK (using sourceKey on source, foreignKey on target)
    // e.g. Invoice hasOne PaymentCurrency: Invoice.currency_id -> PaymentCurrency.id
    //   sourceKey: 'currency_id' (on Invoice), foreignKey: 'id' (on PaymentCurrency)
    sourceKey = inc.sourceKey || assocDef?.sourceKey || 'id';
    foreignKey = inc.foreignKey || assocDef?.foreignKey || 'id';

    // The values we look up from source rows
    const lookupValues = [...new Set(rows.map((r) => r[sourceKey]).filter((v) => v != null))];
    if (lookupValues.length === 0) {
      rows.forEach((r) => (r[as] = inc.required ? undefined : null));
      return;
    }

    // Batched IN query to avoid D1 bind parameter limits
    const relResults = await batchedInQuery(targetTable, foreignKey, lookupValues, inc);

    // Map by foreignKey value
    const relMap = new Map<string, any>();
    for (const r of relResults) {
      const instance = createInstance(targetModel, r);
      if (inc.include?.length) {
        await resolveIncludes([instance], inc.include, targetModel);
      }
      // For hasOne using sourceKey->foreignKey pattern, key by the FK value
      relMap.set(String((r as any)[foreignKey]), instance);
    }

    for (const row of rows) {
      const srcVal = row[sourceKey];
      row[as] = srcVal != null ? relMap.get(String(srcVal)) || null : null;
    }
  }

  // Apply attribute restrictions if specified on the include
  if (inc.attributes) {
    for (const row of rows) {
      if (row[as] && typeof row[as] === 'object') {
        if (Array.isArray(row[as])) {
          row[as] = row[as].map((item: any) => filterAttributes(item, inc.attributes));
        } else {
          row[as] = filterAttributes(row[as], inc.attributes);
        }
      }
    }
  }
}

function filterAttributes(instance: any, attributes: any): any {
  if (!attributes || !instance) return instance;
  if (Array.isArray(attributes)) {
    const result: any = {};
    for (const key of attributes) {
      if (key in instance) result[key] = instance[key];
    }
    return Object.assign(Object.create(Object.getPrototypeOf(instance)), result);
  }
  return instance;
}

function registerAssociation(source: any, type: string, target: any, options?: any) {
  const info = modelRegistry.get(source);
  if (info) {
    info.associations.push({ type: type as any, target, ...options });
  }
}

async function d1Query(sql: string, options?: any): Promise<any> {
  const result = await getDB().prepare(sql).all();
  if (options?.type === 'SELECT') {
    return result.results || [];
  }
  return [result.results || [], result.meta];
}
