import { createHash } from 'node:crypto';
import type { Pool } from 'pg';
import { pgGet, pgRun } from '@/shared/db';
import { logger } from '@/shared/logger';
import type { MessageSearchFilter, MessageSearchHit, SearchResult } from '@/entities/message/types';

// `attributedBody` is already a base64 string on Message, so JSONB storage is a passthrough.
type StoredHit = MessageSearchHit;

function serializeHits(hits: readonly MessageSearchHit[]): StoredHit[] {
  return hits.map((hit) => ({ ...hit }));
}

function deserializeHits(stored: StoredHit[]): MessageSearchHit[] {
  return stored.map((hit) => ({
    ...hit,
    message: {
      ...hit.message,
      attachmentMimeTypes: hit.message.attachmentMimeTypes ?? [],
    },
  }));
}

/**
 * Stable cache key from the search dimensions that affect results.
 * Excludes limit, offset, since, and queryEmbedding.
 */
export function buildCacheKey(
  filter: Pick<MessageSearchFilter, 'query' | 'mode' | 'conversationId' | 'deviceId' | 'provider'>,
): string {
  const h = createHash('sha256');
  h.update(
    JSON.stringify({
      q: filter.query.trim().toLowerCase(),
      mode: filter.mode ?? 'hybrid',
      conv: filter.conversationId ?? '',
      dev: filter.deviceId ?? '',
      prov: filter.provider ?? '',
    }),
  );
  return h.digest('hex');
}

/**
 * Returns cached result only if it was fetched AFTER the last invalidation epoch.
 * This ensures synced-in messages always cause a cache miss on the next search.
 */
export async function getSearchCache(pool: Pool, key: string): Promise<SearchResult | null> {
  const row = await pgGet<{ hits: StoredHit[] | string; total: number }>(
    pool,
    `SELECT sc.hits, sc.total
     FROM macsync.search_cache sc, macsync.search_cache_epoch e
     WHERE sc.cache_key = $1
       AND sc.expires_at > now()
       AND sc.fetched_at > e.last_invalidated_at`,
    [key],
  );
  if (!row) return null;
  const stored: StoredHit[] = typeof row.hits === 'string' ? JSON.parse(row.hits) : row.hits;
  return {
    hits: deserializeHits(stored),
    total: row.total,
  };
}

export async function setSearchCache(pool: Pool, key: string, result: SearchResult): Promise<void> {
  const hits = serializeHits(result.hits);
  await pgRun(
    pool,
    `INSERT INTO macsync.search_cache (cache_key, hits, total, fetched_at, expires_at)
     VALUES ($1, $2::jsonb, $3, now(), now() + INTERVAL '30 days')
     ON CONFLICT (cache_key) DO UPDATE SET
       hits = excluded.hits,
       total = excluded.total,
       fetched_at = excluded.fetched_at,
       expires_at = excluded.expires_at`,
    [key, JSON.stringify(hits), result.total],
  );
  pgRun(pool, `DELETE FROM macsync.search_cache WHERE expires_at <= now()`).catch((err) => {
    logger.warn('search cache eviction failed', { err: String(err) });
  });
}

/**
 * Bumps the epoch watermark so all pre-existing cache entries become stale.
 * Called by bulkUpsertMessages after new data lands — entries fetched before
 * the bump are treated as cache misses on next read, forcing fresh DB queries.
 */
export async function invalidateSearchCache(pool: Pool): Promise<void> {
  await pgRun(
    pool,
    `UPDATE macsync.search_cache_epoch SET last_invalidated_at = now() WHERE id = 1`,
  );
}

/** Returns the epoch watermark — the latest invalidation timestamp. */
export async function getSearchCacheEpoch(pool: Pool): Promise<string> {
  const row = await pgGet<{ last_invalidated_at: string }>(
    pool,
    `SELECT last_invalidated_at FROM macsync.search_cache_epoch WHERE id = 1`,
  );
  return row?.last_invalidated_at ?? '1970-01-01T00:00:00.000Z';
}

export async function clearSearchCache(pool: Pool, key?: string): Promise<void> {
  if (key) {
    await pgRun(pool, `DELETE FROM macsync.search_cache WHERE cache_key = $1`, [key]);
  } else {
    await pgRun(pool, `DELETE FROM macsync.search_cache`);
  }
}
