// src/services/db/flyer.db.ts
import type { Pool, PoolClient } from 'pg';
import { getPool, withTransaction } from './connection.db';
import type { Logger } from 'pino';
import { NotFoundError, handleDbError } from './errors.db';
import { cacheService, CACHE_TTL, CACHE_PREFIX } from '../cacheService.server';
import type {
  Flyer,
  FlyerItem,
  FlyerInsert,
  FlyerItemInsert,
  Brand,
  FlyerDbInsert,
} from '../../types';

export class FlyerRepository {
  // The repository only needs an object with a `query` method, matching the Pool/PoolClient interface.
  // Using `Pick` makes this dependency explicit and simplifies testing by reducing the mock surface.
  private db: Pick<Pool | PoolClient, 'query'>;

  constructor(db: Pick<Pool | PoolClient, 'query'> = getPool()) {
    this.db = db;
  }

  /**
   * Finds a store by name, or creates it if it doesn't exist.
   * This method is designed to be safe for concurrent calls.
   * @param storeName The name of the store.
   * @returns A promise that resolves to the store's ID.
   */
  async findOrCreateStore(storeName: string, logger: Logger): Promise<number> {
    try {
      // Atomically insert the store if it doesn't exist. This is safe from race conditions.
      await this.db.query(
        'INSERT INTO public.stores (name) VALUES ($1) ON CONFLICT (name) DO NOTHING',
        [storeName],
      );

      // Now, the store is guaranteed to exist, so we can safely select its ID.
      const result = await this.db.query<{ store_id: number }>(
        'SELECT store_id FROM public.stores WHERE name = $1',
        [storeName],
      );

      // This case should be virtually impossible if the INSERT...ON CONFLICT logic is correct,
      // as it would mean the store was deleted between the two queries. We throw an error to be safe.
      if (result.rows.length === 0) {
        throw new Error('Failed to find store immediately after upsert operation.');
      }

      return result.rows[0].store_id;
    } catch (error) {
      // Use the centralized error handler for any unexpected database errors.
      handleDbError(
        error,
        logger,
        'Database error in findOrCreateStore',
        { storeName },
        {
          // Any error caught here is unexpected, so we use a generic message.
          defaultMessage: 'Failed to find or create store in database.',
        },
      );
    }
  }

  /**
   * Inserts a new flyer into the database.
   * @param flyerData - The data for the new flyer.
   * @returns The newly created flyer record with its ID.
   */
  async insertFlyer(flyerData: FlyerDbInsert, logger: Logger): Promise<Flyer> {
    console.error(
      '[DB DEBUG] FlyerRepository.insertFlyer called with:',
      JSON.stringify(flyerData, null, 2),
    );
    // Sanitize icon_url: Ensure empty strings become NULL to avoid regex constraint violations
    let iconUrl =
      flyerData.icon_url && flyerData.icon_url.trim() !== '' ? flyerData.icon_url : null;
    let imageUrl = flyerData.image_url || 'placeholder.jpg';

    try {
      // Fallback for tests/workers sending relative URLs to satisfy DB 'url_check' constraint
      const rawBaseUrl = process.env.FRONTEND_URL || 'https://example.com';
      const baseUrl = rawBaseUrl.endsWith('/') ? rawBaseUrl.slice(0, -1) : rawBaseUrl;

      // [DEBUG] Log URL transformation for debugging test failures
      if ((imageUrl && !imageUrl.startsWith('http')) || (iconUrl && !iconUrl.startsWith('http'))) {
        console.error('[DB DEBUG] Transforming relative URLs:', {
          baseUrl,
          originalImage: imageUrl,
          originalIcon: iconUrl,
        });
      }

      if (imageUrl && !imageUrl.startsWith('http')) {
        const cleanPath = imageUrl.startsWith('/') ? imageUrl.substring(1) : imageUrl;
        imageUrl = `${baseUrl}/${cleanPath}`;
      }
      if (iconUrl && !iconUrl.startsWith('http')) {
        const cleanPath = iconUrl.startsWith('/') ? iconUrl.substring(1) : iconUrl;
        iconUrl = `${baseUrl}/${cleanPath}`;
      }

      console.error('[DB DEBUG] Final URLs for insert:', { imageUrl, iconUrl });

      const query = `
        INSERT INTO flyers (
          file_name, image_url, icon_url, checksum, store_id, valid_from, valid_to, store_address,
          status, item_count, uploaded_by
        )
        VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
        RETURNING *;
      `;
      const values = [
        flyerData.file_name, // $1
        imageUrl, // $2
        iconUrl, // $3
        flyerData.checksum, // $4
        flyerData.store_id, // $5
        flyerData.valid_from, // $6
        flyerData.valid_to, // $7
        flyerData.store_address, // $8
        flyerData.status, // $9
        flyerData.item_count, // $10
        flyerData.uploaded_by ?? null, // $11
      ];

      logger.debug(
        { query, values },
        '[DB insertFlyer] Executing insert with the following values.',
      );

      const result = await this.db.query<Flyer>(query, values);
      return result.rows[0];
    } catch (error) {
      console.error('[DB DEBUG] insertFlyer caught error:', error);
      const errorMessage = error instanceof Error ? error.message : '';
      let checkMsg = 'A database check constraint failed.';

      // [ENHANCED LOGGING]
      if (errorMessage.includes('url_check')) {
        logger.error(
          {
            error: errorMessage,
            offendingData: {
              image_url: flyerData.image_url,
              icon_url: flyerData.icon_url, // Log raw input
              sanitized_icon_url:
                flyerData.icon_url && flyerData.icon_url.trim() !== '' ? flyerData.icon_url : null,
            },
          },
          '[DB ERROR] URL Check Constraint Failed. Inspecting URLs.',
        );
      }

      if (errorMessage.includes('flyers_checksum_check')) {
        checkMsg =
          'The provided checksum is invalid or does not meet format requirements (e.g., must be a 64-character SHA-256 hash).';
      } else if (errorMessage.includes('flyers_status_check')) {
        checkMsg = 'Invalid status provided for flyer.';
      } else if (errorMessage.includes('url_check')) {
        checkMsg = `[URL_CHECK_FAIL] Invalid URL format. Image: '${imageUrl}', Icon: '${iconUrl}'`;
      }

      handleDbError(
        error,
        logger,
        'Database error in insertFlyer',
        { flyerData },
        {
          uniqueMessage: 'A flyer with this checksum already exists.',
          fkMessage: 'The specified user or store for this flyer does not exist.',
          checkMessage: checkMsg,
          defaultMessage: 'Failed to insert flyer into database.',
        },
      );
    }
  }

  /**
   * Inserts multiple flyer items into the database for a given flyer ID.
   * @param flyerId - The ID of the parent flyer.
   * @param items - An array of item data to insert.
   * @returns An array of the newly created flyer item records.
   */
  async insertFlyerItems(
    flyerId: number,
    items: FlyerItemInsert[],
    logger: Logger,
  ): Promise<FlyerItem[]> {
    try {
      if (!items || items.length === 0) {
        return [];
      }

      const values: (string | number | null)[] = [];
      const valueStrings: string[] = [];
      let paramIndex = 1;

      for (const item of items) {
        valueStrings.push(
          `($${paramIndex++}, $${paramIndex++}, $${paramIndex++}, $${paramIndex++}, $${paramIndex++}, $${paramIndex++}, $${paramIndex++}, $${paramIndex++})`,
        );

        // Sanitize price_display. The database requires a non-empty string.
        // We provide a default value if the input is null, undefined, or an empty string.
        const priceDisplay =
          item.price_display && item.price_display.trim() !== '' ? item.price_display : 'N/A';

        values.push(
          flyerId,
          item.item,
          priceDisplay,
          item.price_in_cents ?? null,
          item.quantity ?? '',
          item.category_name ?? null,
          item.view_count,
          item.click_count,
        );
      }

      const query = `
        INSERT INTO flyer_items (
          flyer_id, item, price_display, price_in_cents, quantity, category_name, view_count, click_count
        )
        VALUES ${valueStrings.join(', ')}
        RETURNING *;
      `;

      logger.debug(
        { query, values },
        '[DB insertFlyerItems] Executing bulk insert with the following values.',
      );

      const result = await this.db.query<FlyerItem>(query, values);
      return result.rows;
    } catch (error) {
      handleDbError(
        error,
        logger,
        'Database error in insertFlyerItems',
        { flyerId },
        {
          fkMessage: 'The specified flyer, category, master item, or product does not exist.',
          defaultMessage: 'An unknown error occurred while inserting flyer items.',
        },
      );
    }
  }

  /**
   * Retrieves all distinct brands from the stores table.
   * Uses cache-aside pattern with 1-hour TTL (brands rarely change).
   * @returns A promise that resolves to an array of Brand objects.
   */
  async getAllBrands(logger: Logger): Promise<Brand[]> {
    const cacheKey = CACHE_PREFIX.BRANDS;

    return cacheService.getOrSet<Brand[]>(
      cacheKey,
      async () => {
        try {
          const query = `
            SELECT s.store_id as brand_id, s.name, s.logo_url, s.created_at, s.updated_at
            FROM public.stores s
            ORDER BY s.name;
          `;
          const res = await this.db.query<Brand>(query);
          return res.rows;
        } catch (error) {
          handleDbError(
            error,
            logger,
            'Database error in getAllBrands',
            {},
            {
              defaultMessage: 'Failed to retrieve brands from database.',
            },
          );
        }
      },
      { ttl: CACHE_TTL.BRANDS, logger },
    );
  }

  /**
   * Retrieves a single flyer by its ID.
   * @param flyerId The ID of the flyer to retrieve.
   * @returns A promise that resolves to the Flyer object or undefined if not found.
   */
  async getFlyerById(flyerId: number): Promise<Flyer> {
    const res = await this.db.query<Flyer>('SELECT * FROM public.flyers WHERE flyer_id = $1', [
      flyerId,
    ]);
    if (res.rowCount === 0) throw new NotFoundError(`Flyer with ID ${flyerId} not found.`);
    return res.rows[0];
  }

  /**
   * Retrieves all flyers from the database, ordered by creation date.
   * Uses cache-aside pattern with 5-minute TTL.
   * @param limit The maximum number of flyers to return.
   * @param offset The number of flyers to skip.
   * @returns A promise that resolves to an array of Flyer objects.
   */
  async getFlyers(logger: Logger, limit: number = 20, offset: number = 0): Promise<Flyer[]> {
    const cacheKey = `${CACHE_PREFIX.FLYERS}:${limit}:${offset}`;

    return cacheService.getOrSet<Flyer[]>(
      cacheKey,
      async () => {
        try {
          const query = `
            SELECT
              f.*,
              json_build_object(
                'store_id', s.store_id,
                'name', s.name,
                'logo_url', s.logo_url
              ) as store
            FROM public.flyers f
            JOIN public.stores s ON f.store_id = s.store_id
            ORDER BY f.created_at DESC LIMIT $1 OFFSET $2`;
          const res = await this.db.query<Flyer>(query, [limit, offset]);
          return res.rows;
        } catch (error) {
          handleDbError(
            error,
            logger,
            'Database error in getFlyers',
            { limit, offset },
            {
              defaultMessage: 'Failed to retrieve flyers from database.',
            },
          );
        }
      },
      { ttl: CACHE_TTL.FLYERS, logger },
    );
  }

  /**
   * Retrieves all items for a specific flyer.
   * Uses cache-aside pattern with 10-minute TTL.
   * @param flyerId The ID of the flyer.
   * @returns A promise that resolves to an array of FlyerItem objects.
   */
  async getFlyerItems(flyerId: number, logger: Logger): Promise<FlyerItem[]> {
    const cacheKey = `${CACHE_PREFIX.FLYER_ITEMS}:${flyerId}`;

    return cacheService.getOrSet<FlyerItem[]>(
      cacheKey,
      async () => {
        try {
          const res = await this.db.query<FlyerItem>(
            'SELECT * FROM public.flyer_items WHERE flyer_id = $1 ORDER BY flyer_item_id ASC',
            [flyerId],
          );
          return res.rows;
        } catch (error) {
          handleDbError(
            error,
            logger,
            'Database error in getFlyerItems',
            { flyerId },
            {
              defaultMessage: 'Failed to retrieve flyer items from database.',
            },
          );
        }
      },
      { ttl: CACHE_TTL.FLYER_ITEMS, logger },
    );
  }

  /**
   * Retrieves all flyer items for a given list of flyer IDs.
   * @param flyerIds An array of flyer IDs.
   * @returns A promise that resolves to an array of all matching FlyerItem objects.
   */
  async getFlyerItemsForFlyers(flyerIds: number[], logger: Logger): Promise<FlyerItem[]> {
    try {
      const res = await this.db.query<FlyerItem>(
        'SELECT * FROM public.flyer_items WHERE flyer_id = ANY($1::int[]) ORDER BY flyer_id, flyer_item_id ASC',
        [flyerIds],
      );
      return res.rows;
    } catch (error) {
      handleDbError(
        error,
        logger,
        'Database error in getFlyerItemsForFlyers',
        { flyerIds },
        {
          defaultMessage: 'Failed to retrieve flyer items in batch from database.',
        },
      );
    }
  }

  /**
   * Counts the total number of flyer items for a given list of flyer IDs.
   * @param flyerIds An array of flyer IDs.
   * @returns A promise that resolves to the total count of items.
   */
  async countFlyerItemsForFlyers(flyerIds: number[], logger: Logger): Promise<number> {
    try {
      if (flyerIds.length === 0) {
        return 0;
      }
      const res = await this.db.query<{ count: string }>(
        'SELECT COUNT(*) FROM public.flyer_items WHERE flyer_id = ANY($1::int[])',
        [flyerIds],
      );
      return parseInt(res.rows[0].count, 10);
    } catch (error) {
      handleDbError(
        error,
        logger,
        'Database error in countFlyerItemsForFlyers',
        { flyerIds },
        {
          defaultMessage: 'Failed to count flyer items in batch from database.',
        },
      );
    }
  }

  /**
   * Finds a single flyer by its SHA-256 checksum.
   * @param checksum The checksum of the flyer file to find.
   * @returns A promise that resolves to the Flyer object or undefined if not found.
   */
  async findFlyerByChecksum(checksum: string, logger: Logger): Promise<Flyer | undefined> {
    try {
      const res = await this.db.query<Flyer>('SELECT * FROM public.flyers WHERE checksum = $1', [
        checksum,
      ]);
      return res.rows[0];
    } catch (error) {
      handleDbError(
        error,
        logger,
        'Database error in findFlyerByChecksum',
        { checksum },
        {
          defaultMessage: 'Failed to find flyer by checksum in database.',
        },
      );
    }
  }

  /**
   * Tracks a user interaction (view or click) for a specific flyer item.
   * This is a "fire-and-forget" operation from the client's perspective, so errors are logged but not re-thrown.
   * @param flyerItemId The ID of the flyer item.
   * @param interactionType The type of interaction, either 'view' or 'click'.
   */
  async trackFlyerItemInteraction(
    flyerItemId: number,
    interactionType: 'view' | 'click',
    logger: Logger,
  ): Promise<void> {
    try {
      // Choose the column to increment based on the interaction type.
      // This is safe from SQL injection as the input is strictly controlled to be 'view' or 'click'.
      const columnToIncrement = interactionType === 'view' ? 'view_count' : 'click_count';

      const query = `
        UPDATE public.flyer_items
        SET ${columnToIncrement} = ${columnToIncrement} + 1
        WHERE flyer_item_id = $1;
      `;
      await this.db.query(query, [flyerItemId]);
    } catch (error) {
      logger.error(
        { err: error, flyerItemId, interactionType },
        'Database error in trackFlyerItemInteraction (non-critical)',
      );
    }
  }

  /**
   * Deletes a flyer and all its associated items in a transaction.
   * This should typically be an admin-only action.
   * Invalidates related cache entries after successful deletion.
   * @param flyerId The ID of the flyer to delete.
   */
  async deleteFlyer(flyerId: number, logger: Logger): Promise<void> {
    try {
      await withTransaction(async (client) => {
        // The schema is set up with ON DELETE CASCADE for flyer_items,
        // so we only need to delete from the parent 'flyers' table.
        // The database will handle deleting associated flyer_items, unmatched_flyer_items, etc.
        const res = await client.query('DELETE FROM public.flyers WHERE flyer_id = $1', [flyerId]);
        if (res.rowCount === 0) {
          throw new NotFoundError(`Flyer with ID ${flyerId} not found.`);
        }
        logger.info(`Successfully deleted flyer with ID: ${flyerId}`);
      });

      // Invalidate cache after successful deletion
      await cacheService.invalidateFlyer(flyerId, logger);
    } catch (error) {
      handleDbError(
        error,
        logger,
        'Database transaction error in deleteFlyer',
        { flyerId },
        {
          defaultMessage: 'Failed to delete flyer.',
        },
      );
    }
  }
}

/**
 * Creates a new flyer and its associated items within a single database transaction.
 *
 * @param flyerData - The data for the flyer.
 * @param itemsForDb - An array of item data to associate with the flyer.
 * @returns An object containing the new flyer and its items.
 */
export async function createFlyerAndItems(
  flyerData: FlyerInsert,
  itemsForDb: FlyerItemInsert[],
  logger: Logger,
  client: PoolClient,
) {
  // The calling service is now responsible for managing the transaction.
  // This function assumes it is being run within a transaction via the provided client.
  const flyerRepo = new FlyerRepository(client);

  // 1. Find or create the store to get the store_id
  const storeId = await flyerRepo.findOrCreateStore(flyerData.store_name, logger);

  // 2. Prepare the data for the flyer table, replacing store_name with store_id
  const flyerDbData: FlyerDbInsert = { ...flyerData, store_id: storeId };

  // 3. Insert the flyer record
  const newFlyer = await flyerRepo.insertFlyer(flyerDbData, logger);

  // 4. Insert the associated flyer items
  const newItems = await flyerRepo.insertFlyerItems(newFlyer.flyer_id, itemsForDb, logger);

  return { flyer: newFlyer, items: newItems };
}
