import { query } from './db.js';

export interface SearchFilters {
  q?: string;                  // free text
  cpv_division?: string;       // '45000000'
  county_code?: string;        // 'RO113'
  county?: string;             // 'Cluj' (alternative to county_code)
  type?: string;               // 'ca_notice', 'c_notice', etc.
  source?: string;             // 'wsp_canotice' etc.
  value_min?: number;
  value_max?: number;
  date_from?: string;          // 'YYYY-MM-DD'
  date_to?: string;
  procedure_type?: string;
  procedure_state?: string;    // 'Publicat', 'In desfasurare', 'Atribuita', 'Anulata', 'Suspended'
  authority_cui?: string;
  supplier_cui?: string;
  has_lots?: boolean;
  framework_agreement?: boolean;
  only_with_value?: boolean;
  eu_funded?: boolean;         // a.eu_funded matches 'da|true|1'
  sort?: 'date_desc' | 'date_asc' | 'value_desc' | 'value_asc';
  limit?: number;
  offset?: number;
}

export interface SearchResult {
  id: number;
  ref_number: string;
  type: string;
  title: string | null;
  authority_name: string | null;
  authority_cui: string | null;
  authority_county: string | null;
  supplier_name: string | null;
  supplier_cui: string | null;
  cpv_code: string | null;
  cpv_name_ro: string | null;
  cpv_division: string | null;
  cpv_division_name: string | null;
  cpv_emoji: string | null;
  awarded_value: number | null;
  estimated_value: number | null;
  currency: string | null;
  publication_date: string | null;
  procedure_type: string | null;
  procedure_state: string | null;
  has_lots: boolean | null;
  lots_count: number | null;
  source: string;
  seap_url: string | null;
  risk_flags: { code: string; severity: string; label: string }[] | null;
}

export interface SearchResponse {
  total: number;
  results: SearchResult[];
  facets: {
    cpv_divisions: { code: string; name: string; emoji: string | null; count: number }[];
    counties: { code: string; name: string; count: number }[];
    types: { type: string; count: number }[];
    procedures: { procedure: string; count: number }[];
    states: { state: string; count: number }[];
    value_buckets: { bucket: string; count: number; range: [number, number | null] }[];
  };
  sum_awarded: number;
}

export interface ResultCoord {
  id: number;
  role: 'supplier' | 'authority';
  cui: string;
  name: string;
  lat: number;
  lng: number;
}


function buildWhereClause(filters: SearchFilters, params: any[]): string {
  const conds: string[] = [];

  if (filters.q && filters.q.trim()) {
    const q = filters.q.trim();
    // Branch on q shape to keep the planner on a single fast index:
    //  - CUI-shaped ("12345678" or "RO12345678"): use authority_cui / supplier_cui
    //    equality (~1ms via idx_ann_auth_cui + idx_ann_sup_cui BitmapOr).
    //  - Ref-number-shaped (e.g. "CAN1116632", "DA37440345"): ILIKE on ref_number
    //    (no trgm index, but most refs are short ALL-CAPS+digits → cheap prefix).
    //  - Free text (default): tsvector GIN match only (~5–250ms on 781K rows).
    //
    // Mixing all four with OR (previous behavior) forced parallel seq scans of
    // the full 761MB table — ~1.2s per branch × 7 branches in Promise.all = 4.6s
    // wall time on /cauta. With the branching the same workload is
    // index-bound and stays well under 1s.
    const cuiMatch = /^(?:RO\s*)?(\d{2,10})$/i.exec(q);
    const refLike = /^[A-Z]{1,4}\s*\d+$/i.test(q) || /-/.test(q);

    if (cuiMatch) {
      params.push(cuiMatch[1]);
      conds.push(`(a.authority_cui = $${params.length} OR a.supplier_cui = $${params.length})`);
    } else if (refLike) {
      params.push(q);
      const tsIdx = params.length;
      params.push(`%${q}%`);
      const ilIdx = params.length;
      // Free-text-y but still ref-like: OR tsvector with ref ILIKE. ILIKE on
      // ref_number is fast enough on its own (~1s seq scan) but combining with
      // tsvector lets us catch both refs and text mentions in one shot. The
      // planner uses idx_ann_search_tsv + idx_ann_type_ref_number bitmap-OR.
      conds.push(`(
        a.search_tsv @@ plainto_tsquery('simple', seap.immutable_unaccent($${tsIdx}))
        OR a.ref_number ILIKE $${ilIdx}
      )`);
    } else {
      params.push(q);
      conds.push(
        `a.search_tsv @@ plainto_tsquery('simple', seap.immutable_unaccent($${params.length}))`,
      );
    }
  }

  if (filters.cpv_division) {
    // Level-1 codes (root divisions) end in '000000' — the cheaper
    // `a.cpv_division = $N` index lookup matches them exactly.
    // Deeper codes (level 2-4) require prefix-matching against the
    // raw `cpv_code` column (which carries the `-X` checksum suffix).
    const code = filters.cpv_division.trim();
    if (/^\d{2}0{6}$/.test(code)) {
      params.push(code);
      conds.push(`a.cpv_division = $${params.length}`);
    } else if (/^\d{8}$/.test(code)) {
      // Strip trailing zeros so `45100000` matches `45110000-7`, `45112340-9`, etc.
      // We keep at least the 2-digit division so single-zero codes still narrow.
      const trimmed = code.replace(/0+$/, '') || code.substring(0, 2);
      params.push(`${trimmed}%`);
      conds.push(`a.cpv_code LIKE $${params.length}`);
    } else {
      // Unknown shape — fall back to original equality to be safe.
      params.push(code);
      conds.push(`a.cpv_division = $${params.length}`);
    }
  }

  if (filters.county_code) {
    params.push(filters.county_code);
    conds.push(`a.county_code = $${params.length}`);
  } else if (filters.county) {
    params.push(filters.county);
    conds.push(`EXISTS (SELECT 1 FROM seap.cui_location cl WHERE cl.cui = a.authority_cui AND cl.county = $${params.length})`);
  }

  if (filters.type) {
    params.push(filters.type);
    conds.push(`a.type = $${params.length}`);
  }

  if (filters.source) {
    params.push(filters.source);
    conds.push(`a.source = $${params.length}`);
  }

  if (filters.value_min !== undefined && filters.value_min !== null) {
    params.push(filters.value_min);
    conds.push(`a.awarded_value >= $${params.length}`);
  }

  if (filters.value_max !== undefined && filters.value_max !== null) {
    params.push(filters.value_max);
    conds.push(`a.awarded_value <= $${params.length}`);
  }

  if (filters.date_from) {
    params.push(filters.date_from);
    conds.push(`a.publication_date >= $${params.length}::timestamptz`);
  }

  if (filters.date_to) {
    params.push(filters.date_to);
    conds.push(`a.publication_date <= $${params.length}::timestamptz`);
  }

  if (filters.procedure_type) {
    params.push(filters.procedure_type);
    conds.push(`a.procedure_type = $${params.length}`);
  }

  if (filters.procedure_state) {
    params.push(filters.procedure_state);
    conds.push(`a.procedure_state = $${params.length}`);
  }

  if (filters.eu_funded) {
    conds.push(`(a.eu_funded ILIKE 'da' OR a.eu_funded ILIKE 'true' OR a.eu_funded = '1' OR a.eu_funded ILIKE 'yes')`);
  }

  if (filters.authority_cui) {
    params.push(filters.authority_cui);
    conds.push(`a.authority_cui = $${params.length}`);
  }

  if (filters.supplier_cui) {
    params.push(filters.supplier_cui);
    conds.push(`(a.supplier_cui = $${params.length} OR a.supplier_cui = 'RO ' || $${params.length} OR a.supplier_cui = 'RO' || $${params.length})`);
  }

  if (filters.has_lots !== undefined) {
    conds.push(`a.contract_has_lots = ${filters.has_lots ? 'TRUE' : 'FALSE'}`);
  }

  if (filters.framework_agreement !== undefined) {
    conds.push(`a.framework_agreement = ${filters.framework_agreement ? 'TRUE' : 'FALSE'}`);
  }

  if (filters.only_with_value) {
    conds.push(`a.awarded_value IS NOT NULL AND a.awarded_value > 0`);
  }

  return conds.length ? 'WHERE ' + conds.join(' AND ') : '';
}


function orderBy(sort: SearchFilters['sort']): string {
  switch (sort) {
    case 'date_asc': return 'ORDER BY a.publication_date ASC NULLS LAST';
    case 'value_desc': return 'ORDER BY a.awarded_value DESC NULLS LAST';
    case 'value_asc': return 'ORDER BY a.awarded_value ASC NULLS LAST';
    case 'date_desc':
    default: return 'ORDER BY a.publication_date DESC NULLS LAST';
  }
}


/**
 * True when no filter is set — only sort and paging may vary.
 * Used to short-circuit the 6 facet aggregates and totals queries to a
 * pre-computed snapshot table (`public_kpi.cauta_default_*`). Sort doesn't
 * affect facet counts or totals, so we allow any sort here; the main
 * results query runs live with whichever ORDER BY the user picked (the
 * matching DESC NULLS LAST indexes in sql/045 + sql/047 keep it fast).
 * Pagination IS excluded — page 2+ means the user is exploring, not on
 * the cold landing.
 */
function isDefaultBrowse(filters: SearchFilters): boolean {
  return !filters.q
    && !filters.cpv_division
    && !filters.county_code
    && !filters.county
    && !filters.type
    && !filters.source
    && filters.value_min == null
    && filters.value_max == null
    && !filters.date_from
    && !filters.date_to
    && !filters.procedure_type
    && !filters.procedure_state
    && !filters.authority_cui
    && !filters.supplier_cui
    && filters.has_lots == null
    && filters.framework_agreement == null
    && filters.only_with_value == null
    && filters.eu_funded == null
    && (!filters.offset || filters.offset === 0);
}


async function fetchDefaultFacetsAndTotals(): Promise<{
  total: number;
  sum_awarded: number;
  facets: SearchResponse['facets'];
}> {
  const [totals, facets] = await Promise.all([
    query<{ total: string; sum_awarded: string }>(
      `SELECT total::text, sum_awarded::text
         FROM public_kpi.cauta_default_totals WHERE id = 1`
    ),
    query<{ facet_name: string; key: string; label: string | null; emoji: string | null; count: string }>(
      `SELECT facet_name, key, label, emoji, count::text
         FROM public_kpi.cauta_default_facets ORDER BY facet_name, sort_order`
    ),
  ]);

  const t = totals.rows[0];
  const total = Number(t?.total ?? 0);
  const sum_awarded = Number(t?.sum_awarded ?? 0);

  const grouped: Record<string, typeof facets.rows> = {};
  for (const row of facets.rows) {
    (grouped[row.facet_name] = grouped[row.facet_name] || []).push(row);
  }

  const cpv_divisions = (grouped.cpv || []).map(r => ({
    code: r.key, name: r.label || '', emoji: r.emoji, count: Number(r.count),
  }));
  const counties = (grouped.county || []).map(r => ({
    code: r.key, name: r.label || r.key, count: Number(r.count),
  }));
  const types = (grouped.type || []).map(r => ({
    type: r.key, count: Number(r.count),
  }));
  const procedures = (grouped.procedure || []).map(r => ({
    procedure: r.key, count: Number(r.count),
  }));
  const states = (grouped.state || []).map(r => ({
    state: r.key, count: Number(r.count),
  }));

  // Reuse the value-bucket schema from search() (range tuples)
  const valueBucketsTemplate: SearchResponse['facets']['value_buckets'] = [
    { bucket: 'sub 100K',     range: [0,         100_000],     count: 0 },
    { bucket: '100K – 1M',    range: [100_000,   1_000_000],   count: 0 },
    { bucket: '1M – 10M',     range: [1_000_000, 10_000_000],  count: 0 },
    { bucket: '10M – 100M',   range: [10_000_000, 100_000_000], count: 0 },
    { bucket: 'peste 100M',   range: [100_000_000, null],      count: 0 },
    { bucket: 'fără valoare', range: [0, 0],                   count: 0 },
  ];
  for (const row of grouped.value || []) {
    const b = valueBucketsTemplate.find(b => b.bucket === row.key);
    if (b) b.count = Number(row.count);
  }

  return {
    total, sum_awarded,
    facets: {
      cpv_divisions,
      counties,
      types,
      procedures,
      states,
      value_buckets: valueBucketsTemplate,
    },
  };
}


export async function search(filters: SearchFilters): Promise<SearchResponse> {
  const limit = Math.min(filters.limit ?? 30, 100);
  const offset = filters.offset ?? 0;

  // Main results query
  const resultsParams: any[] = [];
  const resultsWhere = buildWhereClause(filters, resultsParams);
  resultsParams.push(limit, offset);

  const resultsQ = `
    SELECT
      a.id, a.ref_number, a.type, a.title,
      a.authority_name, a.authority_cui,
      cl.county AS authority_county,
      a.supplier_name, a.supplier_cui,
      a.cpv_code, a.cpv_name_ro, a.cpv_division,
      c.name_ro AS cpv_division_name, c.emoji AS cpv_emoji,
      a.awarded_value, a.estimated_value, a.currency,
      a.publication_date, a.procedure_type, a.procedure_state,
      a.contract_has_lots AS has_lots, a.lots_count,
      a.source, a.seap_url,
      a.risk_flags
    FROM seap.announcements a
    LEFT JOIN seap.cpv_codes c ON c.code = a.cpv_division
    LEFT JOIN seap.cui_location cl ON cl.cui = a.authority_cui
    ${resultsWhere}
    ${orderBy(filters.sort)}
    LIMIT $${resultsParams.length - 1} OFFSET $${resultsParams.length}
  `;

  const totalParams: any[] = [];
  const totalWhere = buildWhereClause(filters, totalParams);
  const totalQ = `
    SELECT count(*)::int AS total,
           COALESCE(sum(a.awarded_value), 0)::numeric AS sum_awarded
    FROM seap.announcements a
    ${totalWhere}
  `;

  // Facet queries — exclude self-filter to allow toggling
  const facetCpvParams: any[] = [];
  const facetCpvWhere = buildWhereClause({ ...filters, cpv_division: undefined }, facetCpvParams);
  const facetCpvQ = `
    SELECT a.cpv_division AS code, c.name_ro AS name, c.emoji,
           count(*)::int AS count
    FROM seap.announcements a
    LEFT JOIN seap.cpv_codes c ON c.code = a.cpv_division
    ${facetCpvWhere}
    ${facetCpvWhere ? 'AND' : 'WHERE'} a.cpv_division IS NOT NULL
    GROUP BY a.cpv_division, c.name_ro, c.emoji
    ORDER BY count DESC LIMIT 15
  `;

  const facetCountyParams: any[] = [];
  const facetCountyWhere = buildWhereClause({ ...filters, county_code: undefined, county: undefined }, facetCountyParams);
  const facetCountyQ = `
    SELECT a.county_code AS code, a.county_code AS name, count(*)::int AS count
    FROM seap.announcements a
    ${facetCountyWhere}
    ${facetCountyWhere ? 'AND' : 'WHERE'} a.county_code IS NOT NULL
    GROUP BY a.county_code
    ORDER BY count DESC LIMIT 20
  `;

  const facetTypeParams: any[] = [];
  const facetTypeWhere = buildWhereClause({ ...filters, type: undefined }, facetTypeParams);
  const facetTypeQ = `
    SELECT a.type, count(*)::int AS count
    FROM seap.announcements a
    ${facetTypeWhere}
    GROUP BY a.type
    ORDER BY count DESC LIMIT 12
  `;

  const facetProcParams: any[] = [];
  const facetProcWhere = buildWhereClause({ ...filters, procedure_type: undefined }, facetProcParams);
  const facetProcQ = `
    SELECT a.procedure_type AS procedure, count(*)::int AS count
    FROM seap.announcements a
    ${facetProcWhere}
    ${facetProcWhere ? 'AND' : 'WHERE'} a.procedure_type IS NOT NULL
    GROUP BY a.procedure_type
    ORDER BY count DESC LIMIT 10
  `;

  const facetStateParams: any[] = [];
  const facetStateWhere = buildWhereClause({ ...filters, procedure_state: undefined }, facetStateParams);
  const facetStateQ = `
    SELECT a.procedure_state AS state, count(*)::int AS count
    FROM seap.announcements a
    ${facetStateWhere}
    ${facetStateWhere ? 'AND' : 'WHERE'} a.procedure_state IS NOT NULL
    GROUP BY a.procedure_state
    ORDER BY count DESC LIMIT 8
  `;

  const facetValueParams: any[] = [];
  const facetValueWhere = buildWhereClause(
    { ...filters, value_min: undefined, value_max: undefined },
    facetValueParams,
  );
  const facetValueQ = `
    SELECT
      CASE
        WHEN a.awarded_value IS NULL OR a.awarded_value = 0 THEN 'fără valoare'
        WHEN a.awarded_value < 100000 THEN 'sub 100K'
        WHEN a.awarded_value < 1000000 THEN '100K – 1M'
        WHEN a.awarded_value < 10000000 THEN '1M – 10M'
        WHEN a.awarded_value < 100000000 THEN '10M – 100M'
        ELSE 'peste 100M'
      END AS bucket,
      count(*)::int AS count
    FROM seap.announcements a
    ${facetValueWhere}
    GROUP BY bucket
    ORDER BY count DESC
  `;

  // Default browse short-circuit: skip 6 facet aggregates + totals — read
  // snapshot table instead (refreshed nightly via refresh-mvs.sh).
  // Main results query still runs live so the latest data shows up.
  if (isDefaultBrowse(filters)) {
    const [results, defaults] = await Promise.all([
      query<SearchResult>(resultsQ, resultsParams),
      fetchDefaultFacetsAndTotals(),
    ]);
    return {
      total: defaults.total,
      sum_awarded: defaults.sum_awarded,
      results: results.rows,
      facets: defaults.facets,
    };
  }

  const [results, totals, fCpv, fCounty, fType, fProc, fState, fValue] = await Promise.all([
    query<SearchResult>(resultsQ, resultsParams),
    query<{ total: number; sum_awarded: number }>(totalQ, totalParams),
    query<{ code: string; name: string; emoji: string | null; count: number }>(facetCpvQ, facetCpvParams),
    query<{ code: string; name: string; count: number }>(facetCountyQ, facetCountyParams),
    query<{ type: string; count: number }>(facetTypeQ, facetTypeParams),
    query<{ procedure: string; count: number }>(facetProcQ, facetProcParams),
    query<{ state: string; count: number }>(facetStateQ, facetStateParams),
    query<{ bucket: string; count: number }>(facetValueQ, facetValueParams),
  ]);

  const valueBuckets: SearchResponse['facets']['value_buckets'] = [
    { bucket: 'sub 100K',     range: [0,         100_000],     count: 0 },
    { bucket: '100K – 1M',    range: [100_000,   1_000_000],   count: 0 },
    { bucket: '1M – 10M',     range: [1_000_000, 10_000_000],  count: 0 },
    { bucket: '10M – 100M',   range: [10_000_000, 100_000_000], count: 0 },
    { bucket: 'peste 100M',   range: [100_000_000, null],      count: 0 },
    { bucket: 'fără valoare', range: [0, 0],                   count: 0 },
  ];
  for (const row of fValue.rows) {
    const b = valueBuckets.find(b => b.bucket === row.bucket);
    if (b) b.count = row.count;
  }

  return {
    total: totals.rows[0]?.total ?? 0,
    sum_awarded: Number(totals.rows[0]?.sum_awarded ?? 0),
    results: results.rows,
    facets: {
      cpv_divisions: fCpv.rows,
      counties: fCounty.rows,
      types: fType.rows,
      procedures: fProc.rows,
      states: fState.rows,
      value_buckets: valueBuckets,
    },
  };
}


/**
 * Resolve lat/lng for the supplier and authority of each result.
 * Used by the map column on the search page. One query, indexed lookups.
 */
export async function getResultCoords(ids: number[]): Promise<ResultCoord[]> {
  if (ids.length === 0) return [];
  const q = `
    WITH ids AS (SELECT unnest($1::bigint[]) AS id),
    base AS (
      SELECT
        a.id,
        a.supplier_cui,
        a.supplier_name,
        a.authority_cui,
        a.authority_name
      FROM seap.announcements a
      WHERE a.id = ANY($1::bigint[])
    ),
    sup AS (
      SELECT b.id,
             regexp_replace(upper(coalesce(b.supplier_cui,'')), '(^RO)|\\s+', '', 'g') AS norm_cui,
             b.supplier_name AS name
      FROM base b
      WHERE b.supplier_cui IS NOT NULL
    ),
    aut AS (
      SELECT b.id,
             regexp_replace(upper(coalesce(b.authority_cui,'')), '(^RO)|\\s+', '', 'g') AS norm_cui,
             b.authority_name AS name
      FROM base b
      WHERE b.authority_cui IS NOT NULL
    )
    SELECT s.id, 'supplier'::text AS role, s.norm_cui AS cui, s.name, e.lat, e.lng
    FROM sup s
    JOIN firms.entities e ON e.cui = s.norm_cui
    WHERE e.lat IS NOT NULL AND e.lng IS NOT NULL AND s.norm_cui <> ''
    UNION ALL
    SELECT a.id, 'authority'::text AS role, a.norm_cui AS cui, a.name, e.lat, e.lng
    FROM aut a
    JOIN firms.entities e ON e.cui = a.norm_cui
    WHERE e.lat IS NOT NULL AND e.lng IS NOT NULL AND a.norm_cui <> ''
  `;
  const r = await query<ResultCoord>(q, [ids]);
  return r.rows.map(row => ({
    id: Number(row.id),
    role: row.role,
    cui: row.cui,
    name: row.name,
    lat: Number(row.lat),
    lng: Number(row.lng),
  }));
}


// Helper: parse URL search params into typed filters
export function parseSearchParams(url: URL): SearchFilters {
  const sp = url.searchParams;
  const f: SearchFilters = {};
  if (sp.get('q')) f.q = sp.get('q')!;
  if (sp.get('cpv')) f.cpv_division = sp.get('cpv')!;
  if (sp.get('county_code')) f.county_code = sp.get('county_code')!;
  if (sp.get('county')) f.county = sp.get('county')!;
  if (sp.get('type')) f.type = sp.get('type')!;
  if (sp.get('source')) f.source = sp.get('source')!;
  if (sp.get('value_min')) f.value_min = parseFloat(sp.get('value_min')!);
  if (sp.get('value_max')) f.value_max = parseFloat(sp.get('value_max')!);
  if (sp.get('date_from')) f.date_from = sp.get('date_from')!;
  if (sp.get('date_to')) f.date_to = sp.get('date_to')!;
  if (sp.get('procedure_type')) f.procedure_type = sp.get('procedure_type')!;
  if (sp.get('procedure_state')) f.procedure_state = sp.get('procedure_state')!;
  if (sp.get('authority_cui')) f.authority_cui = sp.get('authority_cui')!;
  if (sp.get('supplier_cui')) f.supplier_cui = sp.get('supplier_cui')!;
  if (sp.get('has_lots') === '1') f.has_lots = true;
  if (sp.get('framework') === '1') f.framework_agreement = true;
  if (sp.get('with_value') === '1') f.only_with_value = true;
  if (sp.get('eu_funded') === '1') f.eu_funded = true;
  if (sp.get('sort')) f.sort = sp.get('sort') as any;
  if (sp.get('limit')) f.limit = parseInt(sp.get('limit')!);
  if (sp.get('offset')) f.offset = parseInt(sp.get('offset')!);
  return f;
}


// Build URL with filters (omit empty)
export function buildSearchUrl(base: string, filters: SearchFilters): string {
  const sp = new URLSearchParams();
  if (filters.q) sp.set('q', filters.q);
  if (filters.cpv_division) sp.set('cpv', filters.cpv_division);
  if (filters.county_code) sp.set('county_code', filters.county_code);
  if (filters.county) sp.set('county', filters.county);
  if (filters.type) sp.set('type', filters.type);
  if (filters.value_min !== undefined) sp.set('value_min', String(filters.value_min));
  if (filters.value_max !== undefined) sp.set('value_max', String(filters.value_max));
  if (filters.date_from) sp.set('date_from', filters.date_from);
  if (filters.date_to) sp.set('date_to', filters.date_to);
  if (filters.procedure_type) sp.set('procedure_type', filters.procedure_type);
  if (filters.procedure_state) sp.set('procedure_state', filters.procedure_state);
  if (filters.authority_cui) sp.set('authority_cui', filters.authority_cui);
  if (filters.supplier_cui) sp.set('supplier_cui', filters.supplier_cui);
  if (filters.has_lots) sp.set('has_lots', '1');
  if (filters.framework_agreement) sp.set('framework', '1');
  if (filters.only_with_value) sp.set('with_value', '1');
  if (filters.eu_funded) sp.set('eu_funded', '1');
  // NOTE: offset and limit are intentionally NOT serialized here so that
  // changing a filter via withFilter() resets pagination. Pagination links
  // must use buildSearchUrlWithPagination() instead.
  if (filters.sort) sp.set('sort', filters.sort);
  const s = sp.toString();
  return s ? `${base}?${s}` : base;
}


// Build URL preserving pagination (used by Anterior/Următor links).
export function buildSearchUrlWithPagination(base: string, filters: SearchFilters): string {
  const url = buildSearchUrl(base, filters);
  const sp = new URLSearchParams();
  if (filters.offset !== undefined && filters.offset !== 0) sp.set('offset', String(filters.offset));
  if (filters.limit !== undefined && filters.limit !== 30) sp.set('limit', String(filters.limit));
  const extra = sp.toString();
  if (!extra) return url;
  return url.includes('?') ? `${url}&${extra}` : `${url}?${extra}`;
}
