/**
 * CRUD Helper Functions
 * Provides convenient methods for Create, Read, Update, Delete operations
 */

import { query, queryOne, queryAll } from './db.server.js';

/**
 * Filter a data object to only the columns present in allowedColumns.
 * If allowedColumns is omitted or empty the original object is returned unchanged
 * (backward-compatible default). When a whitelist IS provided, any key not in the
 * list causes an immediate throw — mass-assignment of privileged columns (e.g.
 * role, email_verified) is therefore impossible when callers supply a whitelist.
 * @param {Object} data - Raw data object to filter
 * @param {string[]|undefined} allowedColumns - Explicit column whitelist
 * @returns {Object} Filtered data object
 * @throws {Error} If data contains a column not present in allowedColumns
 */
function filterAllowedColumns(data, allowedColumns) {
  if (!allowedColumns || allowedColumns.length === 0) return data;
  const allowed = new Set(allowedColumns);
  const filtered = {};
  for (const key of Object.keys(data)) {
    if (!allowed.has(key)) {
      throw new Error(`Column "${key}" is not in the permitted columns list`);
    }
    filtered[key] = data[key];
  }
  return filtered;
}

/**
 * Validate and safely double-quote a single SQL identifier (table name, column name).
 * PostgreSQL max identifier length is 63 bytes. Permits only [A-Za-z_][A-Za-z0-9_]*.
 * Any embedded double-quote is escaped per the SQL standard.
 * @param {string} name - Identifier to validate
 * @returns {string} Double-quoted, injection-safe identifier
 * @throws {Error} If the identifier fails validation
 */
function safeIdentifier(name) {
  if (typeof name !== 'string' || name.length === 0 || name.length > 63) {
    throw new Error(`SQL identifier must be a non-empty string of at most 63 characters`);
  }
  if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) {
    throw new Error(`SQL identifier contains disallowed characters: "${name}"`);
  }
  // Double any embedded double-quotes (SQL standard escaping) then wrap.
  return `"${name.replace(/"/g, '""')}"`;
}

/**
 * Validate and return a safe ORDER BY fragment: "<column>" or "<column> ASC|DESC".
 * @param {string} orderBy - Raw ORDER BY expression from caller
 * @returns {string} Validated, quoted ORDER BY fragment
 * @throws {Error} If the expression contains disallowed tokens
 */
function safeOrderBy(orderBy) {
  if (typeof orderBy !== 'string') {
    throw new Error('orderBy must be a string');
  }
  const parts = orderBy.trim().split(/\s+/);
  if (parts.length < 1 || parts.length > 2) {
    throw new Error(`Invalid ORDER BY expression: "${orderBy}"`);
  }
  const col = safeIdentifier(parts[0]);
  if (parts.length === 2) {
    const dir = parts[1].toUpperCase();
    if (dir !== 'ASC' && dir !== 'DESC') {
      throw new Error(`ORDER BY direction must be ASC or DESC, got: "${parts[1]}"`);
    }
    return `${col} ${dir}`;
  }
  return col;
}

/**
 * Build a parameterized WHERE clause from a conditions object.
 * @param {Object} conditions - Column/value pairs to match
 * @param {number} startIndex - First $N placeholder index (default: 1)
 * @returns {{ clause: string, values: Array }} SQL fragment and bound values
 */
function buildWhere(conditions, startIndex = 1) {
  const values = [];
  const clauses = Object.keys(conditions).map((key, index) => {
    values.push(conditions[key]);
    return `${safeIdentifier(key)} = $${startIndex + index}`;
  });
  return { clause: clauses.join(' AND '), values };
}

/**
 * Insert a new record into a table
 * @param {string} tableName - Name of the table
 * @param {Object} data - Object with column names as keys and values to insert
 * @param {Object} [options={}] - Options
 * @param {string[]} [options.allowedColumns] - Whitelist of permitted column names;
 *   any key in data not present in this list throws immediately. Omit only when the
 *   data object is already fully trusted and caller-constructed.
 * @returns {Promise<Object>} Inserted record with all fields
 */
async function create(tableName, data, { allowedColumns } = {}) {
  if (!data || Object.keys(data).length === 0) {
    throw new Error('create() requires at least one data field');
  }
  const safeData = filterAllowedColumns(data, allowedColumns);
  const safeTable = safeIdentifier(tableName);
  const columns = Object.keys(safeData).map(safeIdentifier);
  const values = Object.values(safeData);
  const placeholders = values.map((_, index) => `$${index + 1}`).join(', ');

  const sql = `
    INSERT INTO ${safeTable} (${columns.join(', ')})
    VALUES (${placeholders})
    RETURNING *
  `;

  const result = await query(sql, values);
  return result.rows[0];
}

/**
 * Find a single record by ID
 * @param {string} tableName - Name of the table
 * @param {number|string} id - ID of the record
 * @param {string} idColumn - Name of the ID column (default: 'id')
 * @returns {Promise<Object|null>} Found record or null
 */
async function findById(tableName, id, idColumn = 'id') {
  const sql = `SELECT * FROM ${safeIdentifier(tableName)} WHERE ${safeIdentifier(idColumn)} = $1`;
  return await queryOne(sql, [id]);
}

/**
 * Find records matching conditions
 * @param {string} tableName - Name of the table
 * @param {Object} conditions - Object with column names as keys and values to match
 * @param {Object} options - Query options (limit, offset, orderBy)
 * @returns {Promise<Array>} Array of matching records
 */
async function find(tableName, conditions = {}, options = {}) {
  const { limit, offset, orderBy } = options;

  let sql = `SELECT * FROM ${safeIdentifier(tableName)}`;
  const values = [];

  // Build WHERE clause — column names are validated via safeIdentifier
  if (Object.keys(conditions).length > 0) {
    const { clause, values: whereValues } = buildWhere(conditions);
    values.push(...whereValues);
    sql += ` WHERE ${clause}`;
  }

  // Add ORDER BY — validated and quoted via safeOrderBy
  if (orderBy) {
    sql += ` ORDER BY ${safeOrderBy(orderBy)}`;
  }

  // Add LIMIT — fully parameterized; capped at 10 000 to prevent accidental dumps
  if (limit !== undefined && limit !== null) {
    const parsedLimit = Math.floor(Number(limit));
    if (!Number.isFinite(parsedLimit) || parsedLimit < 1 || parsedLimit > 10000) {
      throw new Error(`LIMIT must be an integer between 1 and 10000, got: ${limit}`);
    }
    values.push(parsedLimit);
    sql += ` LIMIT $${values.length}`;
  }

  // Add OFFSET — fully parameterized
  if (offset !== undefined && offset !== null) {
    const parsedOffset = Math.floor(Number(offset));
    if (!Number.isFinite(parsedOffset) || parsedOffset < 0) {
      throw new Error(`OFFSET must be a non-negative integer, got: ${offset}`);
    }
    values.push(parsedOffset);
    sql += ` OFFSET $${values.length}`;
  }

  return await queryAll(sql, values);
}

/**
 * Find a single record matching conditions
 * @param {string} tableName - Name of the table
 * @param {Object} conditions - Object with column names as keys and values to match
 * @returns {Promise<Object|null>} Found record or null
 */
async function findOne(tableName, conditions) {
  const results = await find(tableName, conditions, { limit: 1 });
  return results.length > 0 ? results[0] : null;
}

/**
 * Update a record by ID
 * @param {string} tableName - Name of the table
 * @param {number|string} id - ID of the record
 * @param {Object} data - Object with column names as keys and new values
 * @param {string} [idColumn='id'] - Name of the ID column
 * @param {Object} [options={}] - Options
 * @param {string[]} [options.allowedColumns] - Whitelist of permitted column names;
 *   any key in data not in this list throws immediately.
 * @returns {Promise<Object|null>} Updated record or null if not found
 */
async function updateById(tableName, id, data, idColumn = 'id', { allowedColumns } = {}) {
  if (!data || Object.keys(data).length === 0) {
    throw new Error('updateById() requires at least one data field');
  }
  const safeData = filterAllowedColumns(data, allowedColumns);
  const safeTable = safeIdentifier(tableName);
  const safeIdCol = safeIdentifier(idColumn);
  const columns = Object.keys(safeData).map(safeIdentifier);
  const values = Object.values(safeData);

  const setClause = columns.map((col, index) => `${col} = $${index + 1}`).join(', ');

  const sql = `
    UPDATE ${safeTable}
    SET ${setClause}
    WHERE ${safeIdCol} = $${values.length + 1}
    RETURNING *
  `;

  const result = await query(sql, [...values, id]);
  return result.rows.length > 0 ? result.rows[0] : null;
}

/**
 * Update records matching conditions
 * @param {string} tableName - Name of the table
 * @param {Object} conditions - Object with column names as keys and values to match
 * @param {Object} data - Object with column names as keys and new values
 * @param {Object} [options={}] - Options
 * @param {string[]} [options.allowedColumns] - Whitelist of permitted column names;
 *   any key in data not in this list throws immediately.
 * @returns {Promise<Array>} Array of updated records
 */
async function update(tableName, conditions, data, { allowedColumns } = {}) {
  // Reject unconditional updates — a missing WHERE clause would silently mutate
  // every row in the table. Callers must always supply at least one condition.
  if (!conditions || Object.keys(conditions).length === 0) {
    throw new Error('update() requires at least one condition to prevent full-table mutation');
  }
  if (!data || Object.keys(data).length === 0) {
    throw new Error('update() requires at least one data field');
  }
  const safeData = filterAllowedColumns(data, allowedColumns);
  const safeTable = safeIdentifier(tableName);
  const dataColumns = Object.keys(safeData).map(safeIdentifier);
  const dataValues = Object.values(safeData);

  const setClause = dataColumns.map((col, index) => `${col} = $${index + 1}`).join(', ');

  const { clause: whereClause, values: whereValues } = buildWhere(conditions, dataValues.length + 1);

  const sql = `
    UPDATE ${safeTable}
    SET ${setClause}
    WHERE ${whereClause}
    RETURNING *
  `;

  const result = await query(sql, [...dataValues, ...whereValues]);
  return result.rows;
}

/**
 * Delete a record by ID
 * @param {string} tableName - Name of the table
 * @param {number|string} id - ID of the record
 * @param {string} idColumn - Name of the ID column (default: 'id')
 * @returns {Promise<boolean>} True if record was deleted, false otherwise
 */
async function deleteById(tableName, id, idColumn = 'id') {
  const sql = `DELETE FROM ${safeIdentifier(tableName)} WHERE ${safeIdentifier(idColumn)} = $1 RETURNING *`;
  const result = await query(sql, [id]);
  return result.rows.length > 0;
}

/**
 * Delete records matching conditions
 * @param {string} tableName - Name of the table
 * @param {Object} conditions - Object with column names as keys and values to match
 * @returns {Promise<number>} Number of deleted records
 */
async function deleteWhere(tableName, conditions) {
  // Reject unconditional deletes — an empty conditions object would generate
  // invalid or full-table SQL. Callers must always supply at least one condition.
  if (!conditions || Object.keys(conditions).length === 0) {
    throw new Error('deleteWhere() requires at least one condition to prevent full-table deletion');
  }
  const { clause, values } = buildWhere(conditions);
  const sql = `DELETE FROM ${safeIdentifier(tableName)} WHERE ${clause} RETURNING *`;
  const result = await query(sql, values);
  return result.rowCount;
}

/**
 * Count records in a table
 * @param {string} tableName - Name of the table
 * @param {Object} conditions - Object with column names as keys and values to match (optional)
 * @returns {Promise<number>} Number of records
 */
async function count(tableName, conditions = {}) {
  let sql = `SELECT COUNT(*) as count FROM ${safeIdentifier(tableName)}`;
  let values = [];

  if (Object.keys(conditions).length > 0) {
    const { clause, values: whereValues } = buildWhere(conditions);
    values = whereValues;
    sql += ` WHERE ${clause}`;
  }

  const result = await queryOne(sql, values);
  return parseInt(result.count, 10);
}

/**
 * Check if a record exists
 * @param {string} tableName - Name of the table
 * @param {Object} conditions - Object with column names as keys and values to match
 * @returns {Promise<boolean>} True if record exists, false otherwise
 */
async function exists(tableName, conditions) {
  const recordCount = await count(tableName, conditions);
  return recordCount > 0;
}

export {
  filterAllowedColumns,
  safeIdentifier,
  safeOrderBy,
  create,
  findById,
  find,
  findOne,
  updateById,
  update,
  deleteById,
  deleteWhere,
  count,
  exists
};

