class SQLiteStatFanout {
  #db;
  #defaultFanout;
  /**
   * Cache of fanout results by table and columns.
   * Key format: "tableName:col1,col2,col3" (sorted alphabetically)
   */
  #cache = /* @__PURE__ */ new Map();
  /**
   * Prepared statements for querying SQLite statistics tables.
   * Prepared once in constructor for performance.
   */
  #stat4Stmt;
  #stat1Stmt;
  #indexStmt;
  /**
   * Creates a new fanout calculator.
   *
   * @param db Database instance
   * @param defaultFanout Default fanout when statistics unavailable (default: 3)
   *                      - 1: Conservative (assumes FK relationships)
   *                      - 3: Moderate (recommended, safe middle ground)
   *                      - 10: SQLite's default (optimistic)
   */
  constructor(db, defaultFanout = 3) {
    this.#db = db;
    this.#defaultFanout = defaultFanout;
    this.#stat4Stmt = this.#db.prepare(`
      SELECT neq, nlt, ndlt, sample
      FROM sqlite_stat4
      WHERE tbl = ? AND idx = ?
      ORDER BY nlt
    `);
    this.#stat1Stmt = this.#db.prepare(`
      SELECT stat
      FROM sqlite_stat1
      WHERE tbl = ? AND idx = ?
    `);
    this.#indexStmt = this.#db.prepare(`
      SELECT il.name as index_name, ii.seqno, ii.name as column_name
      FROM pragma_index_list(?) il
      JOIN pragma_index_info(il.name) ii
      ORDER BY il.seq, ii.seqno
    `);
  }
  /**
   * Gets the fanout factor for join column(s).
   *
   * Fanout = average number of child rows per distinct parent key value(s).
   *
   * ## Strategy
   *
   * 1. Try sqlite_stat4 (best): Histogram with separate NULL/non-NULL samples
   * 2. Fallback to sqlite_stat1: Average across all rows (includes NULLs)
   * 3. Fallback to default: When no statistics available
   *
   * ## Compound Indexes
   *
   * For multi-column joins, finds indexes where ALL columns appear as an
   * exact prefix. Uses the appropriate depth in stat1/stat4.
   *
   * Example:
   * - Columns: `['customerId', 'storeId']`
   * - Matches index: `(customerId, storeId, date)` at depth 2
   * - Uses stat1 parts[2] or stat4 neq[1] for accurate fanout
   *
   * ## Caching
   *
   * Results are cached per (table, columns) combination. Clear the cache if
   * you run ANALYZE to update statistics.
   *
   * @param tableName Table containing the join column(s)
   * @param columns Array of column names (one or more columns)
   * @returns Fanout result with value and source
   */
  getFanout(tableName, columns) {
    const cacheKey = `${tableName}:${[...columns].sort().join(",")}`;
    const cached = this.#cache.get(cacheKey);
    if (cached) {
      return cached;
    }
    const stat4Result = this.#getFanoutFromStat4(tableName, columns);
    if (stat4Result) {
      this.#cache.set(cacheKey, stat4Result);
      return stat4Result;
    }
    const stat1Result = this.#getFanoutFromStat1(tableName, columns);
    if (stat1Result) {
      this.#cache.set(cacheKey, stat1Result);
      return stat1Result;
    }
    const defaultResult = {
      fanout: this.#defaultFanout,
      confidence: "none",
      source: "default"
    };
    this.#cache.set(cacheKey, defaultResult);
    return defaultResult;
  }
  /**
   * Clears the fanout cache.
   * Call this after running ANALYZE to pick up updated statistics.
   */
  clearCache() {
    this.#cache.clear();
  }
  /**
   * Gets fanout from sqlite_stat4 histogram.
   *
   * Queries stat4 samples, decodes to identify NULLs, and returns
   * the median fanout of non-NULL samples.
   *
   * For compound indexes, uses the neq value at the appropriate depth.
   *
   * @param columns Array of column names to get fanout for
   * @returns Fanout result or undefined if stat4 unavailable
   */
  #getFanoutFromStat4(tableName, columns) {
    try {
      const indexInfo = this.#findIndexForColumns(tableName, columns);
      if (!indexInfo) {
        return void 0;
      }
      const samples = this.#stat4Stmt.all(
        tableName,
        indexInfo.indexName
      );
      if (samples.length === 0) {
        return void 0;
      }
      const neqIndex = indexInfo.depth - 1;
      const decodedSamples = samples.map((s) => {
        const neqParts = s.neq.split(" ");
        return {
          fanout: parseInt(neqParts[neqIndex] ?? neqParts[0], 10),
          isNull: this.#decodeSampleIsNull(s.sample)
        };
      });
      const nonNullSamples = decodedSamples.filter((s) => !s.isNull);
      if (nonNullSamples.length === 0) {
        return {
          fanout: 0,
          source: "stat4",
          confidence: "high"
        };
      }
      const fanouts = nonNullSamples.map((s) => s.fanout).sort((a, b) => a - b);
      const medianFanout = fanouts.length % 2 === 0 ? Math.floor(
        (fanouts[fanouts.length / 2 - 1] + fanouts[fanouts.length / 2]) / 2
      ) : fanouts[Math.floor(fanouts.length / 2)];
      return {
        fanout: medianFanout,
        source: "stat4",
        confidence: "high"
      };
    } catch {
      return void 0;
    }
  }
  /**
   * Gets fanout from sqlite_stat1 average.
   *
   * Note: This includes NULL rows in the calculation and may overestimate
   * fanout for sparse foreign keys.
   *
   * For compound indexes, uses the stat value at the appropriate depth.
   *
   * @param columns Array of column names to get fanout for
   * @returns Fanout result or undefined if stat1 unavailable
   */
  #getFanoutFromStat1(tableName, columns) {
    try {
      const indexInfo = this.#findIndexForColumns(tableName, columns);
      if (!indexInfo) {
        return void 0;
      }
      const result = this.#stat1Stmt.get(tableName, indexInfo.indexName);
      if (!result) {
        return void 0;
      }
      const parts = result.stat.split(" ");
      if (parts.length < indexInfo.depth + 1) {
        return void 0;
      }
      const fanout = parseInt(parts[indexInfo.depth], 10);
      if (isNaN(fanout)) {
        return void 0;
      }
      return {
        fanout,
        source: "stat1",
        confidence: "med"
      };
    } catch {
      return void 0;
    }
  }
  /**
   * Finds an index that can be used to get statistics for column(s).
   *
   * Uses pragma_index_list and pragma_index_info to reliably get index
   * column names, avoiding brittle SQL parsing. Includes all indices:
   * user-created (CREATE INDEX), PRIMARY KEY, and UNIQUE constraints.
   *
   * Uses flexible matching: Finds indexes where ALL columns appear in the
   * first N positions, regardless of order. This works because SQLite statistics
   * at depth N represent the fanout for the combination of the first N columns,
   * and combinations are order-independent.
   *
   * Example:
   * - columns: ['customerId', 'storeId']
   * - Matches: (customerId, storeId, date) at depth 2 ✅
   * - Matches: (storeId, customerId, date) at depth 2 ✅ (flexible order)
   * - Does NOT match: (date, customerId, storeId) ❌ (columns not in first 2 positions)
   * - Does NOT match: (customerId, date, storeId) ❌ (storeId not in first 2 positions)
   *
   * @param columns Array of column names (order-independent for matching)
   * @returns Index info with name and depth, or undefined if no match
   */
  #findIndexForColumns(tableName, columns) {
    try {
      const rows = this.#indexStmt.all(tableName);
      const indexMap = /* @__PURE__ */ new Map();
      for (const row of rows) {
        const cols = indexMap.get(row.index_name) ?? [];
        cols.push(row.column_name);
        indexMap.set(row.index_name, cols);
      }
      for (const [indexName, indexColumns] of indexMap) {
        if (this.#isPrefixMatch(columns, indexColumns)) {
          return {
            indexName,
            depth: columns.length
          };
        }
      }
      return void 0;
    } catch {
      return void 0;
    }
  }
  /**
   * Checks if all queryColumns exist in the first N positions of indexColumns,
   * regardless of order.
   *
   * This allows flexible matching: constraint {a, b} matches both index (a, b, c)
   * and index (b, a, c) at depth 2, since both represent the fanout for the
   * combination of columns a and b.
   *
   * Gaps are NOT allowed: constraint {a, c} does NOT match index (a, b, c)
   * because no depth represents just (a, c) without b. Statistics are cumulative
   * from position 0.
   *
   * @param queryColumns Columns we're looking for (from constraint)
   * @param indexColumns Columns in the index (in order)
   * @returns true if all queryColumns exist in indexColumns[0...queryColumns.length-1]
   */
  #isPrefixMatch(queryColumns, indexColumns) {
    if (queryColumns.length > indexColumns.length) {
      return false;
    }
    const indexPrefix = indexColumns.slice(0, queryColumns.length);
    const indexPrefixLower = new Set(indexPrefix.map((col) => col.toLowerCase()));
    const queryColumnsLower = queryColumns.map((col) => col.toLowerCase());
    return queryColumnsLower.every((queryCol) => indexPrefixLower.has(queryCol));
  }
  /**
   * Decodes a sqlite_stat4 sample value to check if it's NULL.
   *
   * SQLite record format (simplified):
   * - Varint: header size
   * - Serial types for each column (one byte each typically)
   * - Actual data
   *
   * Serial type 0 = NULL
   * Serial type 1 = 8-bit int
   * Serial type 2 = 16-bit int
   * Serial type 3 = 24-bit int
   * etc.
   *
   * We only need to check the first column's serial type.
   *
   * @param sample Binary-encoded sample from stat4
   * @returns true if the sample value is NULL
   */
  #decodeSampleIsNull(sample) {
    if (sample.length === 0) {
      return true;
    }
    const headerSize = sample[0];
    if (headerSize === 0 || headerSize >= sample.length) {
      return true;
    }
    const serialType = sample[1];
    return serialType === 0;
  }
}
export {
  SQLiteStatFanout
};
//# sourceMappingURL=sqlite-stat-fanout.js.map
