const express = require('express');
const axios = require('axios');
const fs = require('fs');
const path = require('path');
const readline = require('readline');
const db = require('../db/models');
const wrapAsync = require('../helpers').wrapAsync;
const commonErrorHandler = require('../helpers').commonErrorHandler;

const router = express.Router();
const Sequelize = db.Sequelize;
const Op = Sequelize.Op;

const DEFAULT_LIMIT = 24;
const MAX_LIMIT = 100;
const CANDIDATE_LIMIT = 500;
const DEFAULT_RADIUS_KM = 5;
const GLOBAL_RADIUS_KM = 20038;
const MIN_NEAREST_RESULTS = 10;
const OSM_PROFILE_MATCH_LIMIT = 6;
const MAX_OSM_EXPANSION_RADIUS_KM = 150;
const OSM_OVERPASS_QUERY_TIMEOUT_SECONDS = 15;
const OSM_HTTP_TIMEOUT_MS = 18000;
const OSM_LIVE_SEARCH_MAX_LIMIT = 36;

const GEO_SCORE_FORMULA = {
  distance: 60,
  relevance: 20,
  reputation: 10,
  activity: 5,
  interaction: 5,
};

const RADIUS_ZONES = [
  {
    value: 1,
    label: 'Sangat dekat (jalan kaki)',
    range: '0–1 km',
    description: 'Prioritas tertinggi untuk hasil yang bisa ditempuh dengan jalan kaki.',
  },
  {
    value: 5,
    label: 'Dekat',
    range: '1–5 km',
    description: 'Masih dekat dari lokasi aktif untuk kebutuhan harian sekitar.',
  },
  {
    value: 10,
    label: 'Agak dekat',
    range: '5–10 km',
    description: 'Masih mudah dijangkau, biasanya perlu kendaraan singkat.',
  },
  {
    value: 20,
    label: 'Sedang',
    range: '10–20 km',
    description: 'Jarak menengah dan ditampilkan setelah hasil yang lebih dekat.',
  },
  {
    value: 100,
    label: 'Jauh',
    range: '20–100 km',
    description: 'Di luar prioritas jarak utama, digunakan saat radius diperluas.',
  },
  {
    value: 500,
    label: 'Sangat jauh',
    range: '100–500 km',
    description: 'Skala provinsi untuk pencarian yang lebih luas.',
  },
  {
    value: GLOBAL_RADIUS_KM,
    label: 'Nasional/Global',
    range: '500+ km',
    description: 'Skala nasional/global ketika lokasi lokal tidak cukup.',
  },
];

const DISTANCE_BUCKETS = [1, 5, 10, 20, 100, 500, GLOBAL_RADIUS_KM];

const STOCK_STATUS_LABELS = {
  in_stock: 'Tersedia',
  limited: 'Stok terbatas',
  out_of_stock: 'Habis',
  by_request: 'By request',
};

const STOP_WORDS = new Set([
  'ada',
  'aku',
  'atau',
  'buka',
  'cari',
  'dan',
  'dekat',
  'desa',
  'di',
  'dengan',
  'direkomendasikan',
  'dalam',
  'enak',
  'ini',
  'itu',
  'jam',
  'ke',
  'kecamatan',
  'kelurahan',
  'km',
  'kota',
  'lokasi',
  'me',
  'murah',
  'near',
  'paling',
  'populer',
  'radius',
  'ramai',
  'rating',
  'rumah',
  'saya',
  'sekarang',
  'sekitar',
  'terbaru',
  'terdekat',
  'terlaris',
  'tertinggi',
  'terverifikasi',
  'untuk',
  'yang',
  '10',
  '24',
]);

const SYNONYMS = {
  ac: ['air', 'conditioner', 'pendingin', 'service', 'servis', 'jasa'],
  apotek: ['farmasi', 'obat', 'kesehatan', 'herbal', 'jamu', 'madu'],
  atm: ['bank', 'uang', 'tunai', 'lokal', 'toko', 'umkm'],
  aki: ['baterai', 'mobil', 'bengkel'],
  artikel: ['blog', 'berita', 'website', 'konten'],
  auto: ['automotif', 'otomotif', 'mobil', 'kendaraan', 'bengkel'],
  ban: ['roda', 'spooring', 'balancing', 'mobil', 'bengkel'],
  belanja: ['toko', 'produk', 'marketplace', 'jual', 'barang'],
  cuci: ['mobil', 'bengkel', 'otomotif', 'auto', 'kendaraan'],
  dokter: ['klinik', 'kesehatan', 'herbal', 'sehat', 'obat'],
  bengkel: [
    'servis',
    'service',
    'otomotif',
    'automotif',
    'auto',
    'mobil',
    'kendaraan',
    'oli',
    'tune',
    'mesin',
    'kaki',
  ],
  booking: ['reservasi', 'jadwal', 'pesan', 'jasa'],
  cafe: [
    'kafe',
    'kopi',
    'coffee',
    'kedai',
    'warung',
    'kuliner',
    'makan',
    'minum',
  ],
  clinic: ['klinik', 'kesehatan', 'dokter', 'obat'],
  coffee: ['cafe', 'kafe', 'kopi', 'kedai'],
  dapur: ['kitchen', 'kabinet', 'interior', 'lemari'],
  dekor: ['dekorasi', 'interior', 'furniture', 'furnitur', 'mebel', 'perabot'],
  desain: ['design', 'interior', 'renovasi', 'dekorasi', 'jasa'],
  furniture: ['furnitur', 'mebel', 'perabot', 'sofa', 'lemari', 'interior'],
  furnitur: ['furniture', 'mebel', 'perabot', 'sofa', 'lemari', 'interior'],
  herbal: ['sehat', 'kesehatan', 'alami', 'jamu', 'madu', 'temulawak'],
  hotel: ['penginapan', 'wisata', 'travel', 'lokal', 'kuliner', 'cafe'],
  interior: [
    'furniture',
    'furnitur',
    'mebel',
    'perabot',
    'sofa',
    'lemari',
    'kitchen',
    'set',
    'dekorasi',
    'desain',
  ],
  jasa: ['layanan', 'service', 'servis', 'booking'],
  kaki: ['shockbreaker', 'bushing', 'bearing', 'rem', 'bengkel', 'mobil'],
  kafe: [
    'cafe',
    'kopi',
    'coffee',
    'kedai',
    'warung',
    'kuliner',
    'makan',
    'minum',
  ],
  kabinet: ['lemari', 'kitchen', 'dapur', 'interior'],
  kedai: ['cafe', 'kafe', 'kopi', 'warung', 'kuliner'],
  kitchen: ['dapur', 'kabinet', 'interior', 'furniture', 'furnitur', 'lemari'],
  kopi: ['cafe', 'kafe', 'coffee', 'kedai', 'warung', 'melayu', 'saring'],
  kuliner: ['cafe', 'kafe', 'kedai', 'warung', 'makan', 'minum'],
  klinik: ['clinic', 'dokter', 'kesehatan', 'herbal', 'sehat', 'obat'],
  laksa: ['melayu', 'makan', 'kuliner', 'kedai'],
  lemari: ['kabinet', 'furniture', 'furnitur', 'mebel', 'perabot', 'interior'],
  makan: ['kuliner', 'cafe', 'kafe', 'kedai', 'warung'],
  marketplace: ['produk', 'stok', 'jual', 'toko', 'belanja'],
  mall: ['belanja', 'toko', 'marketplace', 'produk', 'kuliner'],
  minimarket: ['belanja', 'toko', 'marketplace', 'produk'],
  mebel: ['furniture', 'furnitur', 'perabot', 'sofa', 'lemari', 'interior'],
  melayu: [
    'riau',
    'tradisional',
    'kopi',
    'roti',
    'jala',
    'laksa',
    'teh',
    'tarik',
    'saring',
  ],
  mobil: [
    'bengkel',
    'servis',
    'service',
    'otomotif',
    'auto',
    'kendaraan',
    'oli',
    'mesin',
  ],
  nasi: ['makan', 'kuliner', 'melayu', 'lemak'],
  obat: ['apotek', 'farmasi', 'kesehatan', 'herbal', 'jamu'],
  oli: ['pelumas', 'ganti', 'servis', 'service', 'bengkel', 'mobil'],
  otomotif: [
    'automotif',
    'auto',
    'bengkel',
    'mobil',
    'kendaraan',
    'servis',
    'service',
  ],
  pembayaran: ['bayar', 'digital', 'cashless', 'qris'],
  pantai: ['wisata', 'travel', 'penginapan', 'hotel', 'lokal'],
  pasar: ['belanja', 'toko', 'marketplace', 'produk'],
  perabot: ['furniture', 'furnitur', 'mebel', 'interior', 'sofa', 'lemari'],
  produk: ['barang', 'stok', 'ready', 'tersedia'],
  penginapan: ['hotel', 'wisata', 'travel', 'lokal'],
  rem: ['brake', 'bengkel', 'mobil', 'kaki'],
  restoran: [
    'restaurant',
    'kuliner',
    'makan',
    'warung',
    'cafe',
    'kafe',
    'kedai',
    'nasi',
  ],
  restaurant: [
    'restoran',
    'kuliner',
    'makan',
    'warung',
    'cafe',
    'kafe',
    'kedai',
  ],
  roti: ['jala', 'melayu', 'kuliner', 'makan'],
  saring: ['kopi', 'melayu', 'kedai'],
  sekolah: ['pendidikan', 'kursus', 'belajar'],
  service: [
    'servis',
    'bengkel',
    'mobil',
    'otomotif',
    'auto',
    'oli',
    'tune',
    'mesin',
  ],
  sakit: ['kesehatan', 'klinik', 'dokter', 'obat', 'herbal'],
  servis: [
    'service',
    'bengkel',
    'mobil',
    'otomotif',
    'auto',
    'oli',
    'tune',
    'mesin',
  ],
  shockbreaker: ['shock', 'kaki', 'mobil', 'bengkel'],
  sofa: ['furniture', 'furnitur', 'mebel', 'interior', 'ruang', 'tamu'],
  bbm: ['spbu', 'bensin', 'pom', 'pertamina', 'shell', 'vivo', 'fuel'],
  bensin: ['spbu', 'bbm', 'pom', 'pertamina', 'shell', 'vivo', 'fuel'],
  fuel: ['spbu', 'bbm', 'bensin', 'pom', 'gas', 'station'],
  gas: ['spbu', 'bbm', 'bensin', 'pom', 'fuel', 'station'],
  pertamina: ['spbu', 'bbm', 'bensin', 'pom', 'fuel'],
  pom: ['spbu', 'bbm', 'bensin', 'pertamina', 'shell', 'vivo', 'fuel'],
  shell: ['spbu', 'bbm', 'bensin', 'pom', 'fuel'],
  spbu: ['bbm', 'bensin', 'pom', 'pertamina', 'shell', 'vivo', 'fuel'],
  spooring: ['balancing', 'ban', 'bengkel', 'mobil'],
  stok: ['stock', 'tersedia', 'ready', 'produk', 'barang'],
  tersedia: ['ready', 'stok', 'stock', 'in stock'],
  tambal: ['ban', 'bengkel', 'otomotif', 'mobil', 'roda'],
  toko: ['store', 'shop', 'belanja', 'jual', 'produk'],
  tempat: ['lokal', 'wisata', 'kuliner', 'toko', 'umkm'],
  tune: ['servis', 'service', 'mesin', 'bengkel', 'mobil'],
  umkm: ['usaha', 'lokal', 'toko', 'jasa', 'produk'],
  warung: ['kedai', 'cafe', 'kafe', 'kopi', 'kuliner', 'makan'],
  wisata: ['travel', 'hotel', 'penginapan', 'pantai', 'lokal', 'kuliner'],
};


Object.assign(SYNONYMS, {
  aksesoris: ['fashion', 'toko', 'belanja', 'produk'],
  aplikasi: ['website', 'digital', 'it', 'komputer', 'jasa'],
  bayi: ['perlengkapan', 'toko', 'produk', 'belanja'],
  barbershop: ['salon', 'hairdresser', 'kecantikan', 'potong', 'rambut'],
  bazar: ['event', 'umkm', 'pasar', 'toko', 'lokal'],
  bibit: ['tanaman', 'pertanian', 'pupuk', 'garden', 'toko'],
  buku: ['bookstore', 'toko', 'stationery', 'sekolah'],
  catering: ['makanan', 'kuliner', 'restoran', 'warung'],
  cctv: ['kamera', 'elektronik', 'komputer', 'internet', 'jasa'],
  digital: ['marketing', 'seo', 'website', 'aplikasi', 'jasa'],
  ekspedisi: ['kurir', 'logistik', 'pengiriman', 'paket', 'pos'],
  event: ['seminar', 'festival', 'bazar', 'organizer', 'venue'],
  fashion: ['baju', 'pakaian', 'sepatu', 'tas', 'aksesoris', 'toko'],
  freelance: ['lowongan', 'kerja', 'jasa', 'konsultan'],
  grooming: ['pet', 'hewan', 'peliharaan', 'klinik', 'veterinary'],
  hp: ['smartphone', 'handphone', 'ponsel', 'mobile', 'elektronik'],
  internet: ['wifi', 'telkom', 'telekomunikasi', 'komputer', 'jasa'],
  kamera: ['cctv', 'fotografer', 'videografer', 'elektronik'],
  kecantikan: ['salon', 'barbershop', 'skincare', 'kosmetik', 'beauty'],
  kerja: ['lowongan', 'freelance', 'magang', 'employment', 'jasa'],
  komputer: ['laptop', 'printer', 'service', 'servis', 'elektronik', 'it'],
  konsultan: ['jasa', 'bisnis', 'hukum', 'keuangan', 'pajak'],
  kosmetik: ['skincare', 'beauty', 'kecantikan', 'toko'],
  kurir: ['ekspedisi', 'logistik', 'pengiriman', 'paket', 'antar'],
  laptop: ['komputer', 'printer', 'service', 'servis', 'elektronik'],
  laundry: ['binatu', 'cuci', 'pakaian', 'jasa'],
  lowongan: ['kerja', 'freelance', 'magang', 'employment'],
  mainan: ['toys', 'anak', 'bayi', 'toko', 'produk'],
  material: ['bangunan', 'hardware', 'semen', 'cat', 'keramik', 'toko'],
  motor: ['bengkel', 'sparepart', 'dealer', 'otomotif', 'kendaraan'],
  notaris: ['hukum', 'legal', 'pengacara', 'jasa'],
  pajak: ['konsultan', 'akuntan', 'accountant', 'jasa'],
  pakan: ['ternak', 'peternakan', 'hewan', 'feed', 'toko'],
  pengacara: ['hukum', 'lawyer', 'notaris', 'jasa'],
  pengiriman: ['kurir', 'ekspedisi', 'logistik', 'paket', 'pos'],
  penjual: ['jual', 'toko', 'produk', 'umkm', 'seller'],
  perhiasan: ['jewelry', 'emas', 'aksesoris', 'toko'],
  printer: ['komputer', 'laptop', 'service', 'servis', 'elektronik'],
  properti: ['rumah', 'tanah', 'ruko', 'apartemen', 'kost', 'kontrakan'],
  pupuk: ['pertanian', 'bibit', 'tanaman', 'pestisida', 'toko'],
  renovasi: ['bangunan', 'tukang', 'kontraktor', 'material', 'jasa'],
  rental: ['sewa', 'mobil', 'motor', 'travel', 'jasa'],
  ruko: ['properti', 'rumah', 'tanah', 'estate', 'agent'],
  rumah: ['properti', 'renovasi', 'bangunan', 'dijual'],
  samsat: ['pajak', 'kendaraan', 'pemerintah', 'layanan', 'publik'],
  seo: ['digital', 'marketing', 'website', 'jasa'],
  skincare: ['kosmetik', 'beauty', 'kecantikan', 'toko'],
  smartphone: ['hp', 'handphone', 'ponsel', 'mobile', 'elektronik'],
  sparepart: ['suku', 'cadang', 'motor', 'mobil', 'otomotif', 'bengkel'],
  tas: ['fashion', 'bag', 'toko', 'aksesoris'],
  ternak: ['peternakan', 'pakan', 'hewan', 'feed', 'toko'],
  tukang: ['bangunan', 'renovasi', 'jasa', 'kontraktor'],
  warteg: ['warung', 'tegal', 'restoran', 'kuliner', 'cafe', 'kafe', 'kedai'],
  website: ['digital', 'aplikasi', 'seo', 'komputer', 'jasa'],
  wedding: ['event', 'organizer', 'fotografer', 'videografer', 'jasa'],
  wifi: ['internet', 'telkom', 'telekomunikasi', 'komputer', 'jasa'],
});


Object.assign(SYNONYMS, {
  aesthetic: ['cafe', 'kafe', 'kopi', 'nongkrong', 'wisata'],
  alfamart: ['minimarket', 'supermarket', 'toko', 'belanja'],
  alun: ['wisata', 'taman', 'rekreasi'],
  apartemen: ['apartment', 'properti', 'penginapan', 'sewa'],
  band: ['bandara', 'airport', 'transportasi'],
  bandara: ['airport', 'aerodrome', 'transportasi', 'travel'],
  barbershop: ['pangkas', 'rambut', 'salon', 'hairdresser'],
  bca: ['atm', 'bank', 'tunai'],
  bekasi: ['kota', 'lokasi', 'jabodetabek'],
  bidan: ['klinik', 'kesehatan', 'dokter', 'midwife'],
  bmkg: ['cuaca', 'prakiraan', 'pemerintah', 'informasi', 'lokal'],
  bni: ['atm', 'bank', 'tunai'],
  boba: ['minuman', 'cafe', 'kafe', 'kedai', 'beverage'],
  bogor: ['kota', 'lokasi', 'jabodetabek'],
  bri: ['atm', 'bank', 'tunai'],
  busway: ['halte', 'bus', 'transportasi'],
  cikarang: ['kota', 'lokasi', 'bekasi', 'industri'],
  cuaca: ['bmkg', 'prakiraan', 'ramalan', 'pemerintah', 'informasi', 'lokal'],
  depok: ['kota', 'lokasi', 'jabodetabek'],
  earth: ['maps', 'peta', 'navigasi', 'lokasi'],
  es: ['minuman', 'cafe', 'kafe', 'kedai'],
  fitnes: ['fitness', 'gym', 'olahraga'],
  franchise: ['waralaba', 'minuman', 'cafe', 'toko'],
  google: ['maps', 'peta', 'navigasi', 'lokasi'],
  halte: ['bus', 'busway', 'transportasi', 'terminal'],
  herbal: ['jamu', 'apotek', 'obat', 'kesehatan'],
  homestay: ['penginapan', 'hotel', 'guesthouse', 'wisata'],
  indomaret: ['minimarket', 'supermarket', 'toko', 'belanja'],
  jakarta: ['kota', 'lokasi', 'jabodetabek'],
  jajanan: ['makanan', 'kuliner', 'cemilan', 'snack', 'pasar'],
  karawang: ['kota', 'lokasi', 'industri'],
  katering: ['catering', 'makanan', 'kuliner', 'sehat'],
  kolam: ['renang', 'swimming', 'olahraga', 'rekreasi'],
  kontrakan: ['properti', 'kost', 'sewa', 'rumah'],
  kos: ['kost', 'properti', 'sewa', 'penginapan'],
  kost: ['kos', 'properti', 'sewa', 'penginapan'],
  lapangan: ['futsal', 'olahraga', 'sport', 'rekreasi'],
  mandiri: ['atm', 'bank', 'tunai'],
  maps: ['peta', 'google', 'navigasi', 'lokasi', 'tempat', 'restoran', 'cafe', 'hotel', 'spbu', 'atm', 'toko', 'wisata', 'stasiun'],
  minuman: ['beverage', 'cafe', 'kafe', 'kopi', 'boba', 'es', 'kedai'],
  mushola: ['musala', 'masjid', 'ibadah'],
  navigasi: ['maps', 'peta', 'waze', 'rute', 'lokasi', 'transportasi'],
  nongkrong: ['cafe', 'kafe', 'kopi', 'kedai', 'kuliner'],
  pasar: ['marketplace', 'toko', 'belanja', 'jajanan'],
  pangkas: ['barbershop', 'rambut', 'salon', 'hairdresser'],
  peta: ['maps', 'google', 'navigasi', 'lokasi', 'tempat', 'rute', 'jalan'],
  pertamini: ['spbu', 'bbm', 'bensin', 'pom', 'fuel'],
  pijat: ['massage', 'spa', 'kecantikan', 'wellness'],
  prakiraan: ['cuaca', 'bmkg', 'ramalan'],
  puskesmas: ['klinik', 'rumah sakit', 'kesehatan', 'dokter'],
  ramalan: ['cuaca', 'prakiraan', 'bmkg'],
  renang: ['kolam', 'swimming', 'olahraga', 'rekreasi'],
  satellite: ['satelit', 'maps', 'peta', 'google', 'earth'],
  satelit: ['satellite', 'maps', 'peta'],
  sd: ['sekolah', 'pendidikan'],
  sehat: ['kesehatan', 'makanan', 'katering', 'herbal'],
  simpel: ['makanan', 'kuliner', 'resep'],
  sma: ['sekolah', 'pendidikan'],
  smp: ['sekolah', 'pendidikan'],
  spa: ['pijat', 'massage', 'kecantikan', 'wellness'],
  stasiun: ['kereta', 'railway', 'transportasi', 'terminal'],
  street: ['view', 'maps', 'peta', 'navigasi'],
  tangerang: ['kota', 'lokasi', 'jabodetabek'],
  terminal: ['bus', 'stasiun', 'transportasi'],
  view: ['street', 'maps', 'peta', 'navigasi'],
  viral: ['populer', 'kuliner', 'makanan', 'minuman'],
  warkop: ['warung', 'kopi', 'cafe', 'kafe', 'kedai'],
  waze: ['maps', 'peta', 'navigasi', 'rute', 'jalan'],
});


const STRICT_CULINARY_QUERY_TOKENS = new Set([
  'ayam',
  'bakso',
  'catering',
  'kuliner',
  'makanan',
  'minuman',
  'restoran',
  'soto',
  'seafood',
  'warteg',
  'warung',
]);

const CULINARY_GUARD_TERMS = [
  'ayam',
  'bakso',
  'catering',
  'food',
  'kedai',
  'kopi',
  'kue',
  'kuliner',
  'makanan',
  'minuman',
  'nasi',
  'restaurant',
  'restoran',
  'roti',
  'seafood',
  'soto',
  'warung',
];

const parseCoordinate = (value, label) => {
  if (value === undefined || value === null || value === '') {
    return null;
  }

  const number = Number(value);
  if (!Number.isFinite(number)) {
    const error = new Error(`${label} harus berupa angka.`);
    error.code = 400;
    throw error;
  }

  return number;
};

const requireSearchOrigin = (lat, lng) => {
  if (lat !== null && lng !== null) {
    return { lat, lng };
  }

  const error = new Error(
    'Lokasi pengguna wajib dikirim. Izinkan akses lokasi browser dan kirim lat/lng.',
  );
  error.code = 400;
  throw error;
};

const parseRadius = (value) => {
  if (value === undefined || value === null || value === '') {
    return DEFAULT_RADIUS_KM;
  }

  const number = Number(value);
  if (!Number.isFinite(number) || number <= 0) {
    const error = new Error('Radius harus berupa angka lebih dari 0 km.');
    error.code = 400;
    throw error;
  }

  return Math.min(number, GLOBAL_RADIUS_KM);
};

const clampScore = (value) => Math.max(0, Math.min(100, Number(value) || 0));

const toNumber = (value) => {
  if (value === null || value === undefined || value === '') {
    return null;
  }

  const number = Number(value);
  return Number.isFinite(number) ? number : null;
};

const normalizeText = (value) =>
  String(value || '')
    .toLowerCase()
    .normalize('NFD')
    .replace(/[\u0300-\u036f]/g, '')
    .replace(/[^a-z0-9]+/g, ' ')
    .trim();


const QUERY_LOCATION_ORIGINS = [
  {
    label: 'Jakarta',
    aliases: ['jakarta', 'dki jakarta', 'jakarta pusat', 'kuliner jakarta'],
    lat: -6.2,
    lng: 106.816666,
    minimumRadiusKm: 25,
  },
  {
    label: 'Bogor',
    aliases: ['bogor', 'kota bogor', 'kuliner bogor'],
    lat: -6.595038,
    lng: 106.816635,
    minimumRadiusKm: 25,
  },
  {
    label: 'Bekasi',
    aliases: ['bekasi', 'kota bekasi', 'kuliner bekasi'],
    lat: -6.23827,
    lng: 106.975573,
    minimumRadiusKm: 25,
  },
  {
    label: 'Tangerang',
    aliases: ['tangerang', 'kota tangerang', 'kuliner tangerang'],
    lat: -6.178306,
    lng: 106.631889,
    minimumRadiusKm: 25,
  },
  {
    label: 'Tangerang Selatan',
    aliases: ['tangerang selatan', 'tangsel', 'kuliner tangerang selatan'],
    lat: -6.28862,
    lng: 106.71789,
    minimumRadiusKm: 25,
  },
  {
    label: 'Depok',
    aliases: ['depok', 'kota depok', 'kuliner depok'],
    lat: -6.402484,
    lng: 106.794243,
    minimumRadiusKm: 25,
  },
  {
    label: 'Cikarang',
    aliases: ['cikarang', 'kuliner cikarang'],
    lat: -6.307923,
    lng: 107.172085,
    minimumRadiusKm: 25,
  },
  {
    label: 'Karawang',
    aliases: ['karawang', 'kuliner karawang'],
    lat: -6.32273,
    lng: 107.33758,
    minimumRadiusKm: 25,
  },
  {
    label: 'Bandung',
    aliases: ['bandung', 'kota bandung', 'wisata bandung'],
    lat: -6.917464,
    lng: 107.619123,
    minimumRadiusKm: 25,
  },
  {
    label: 'Indonesia (titik awal Jakarta)',
    aliases: ['indonesia', 'peta indonesia', 'maps indonesia'],
    lat: -6.2,
    lng: 106.816666,
    minimumRadiusKm: 100,
  },
];

const DEFAULT_LOCAL_SEO_ORIGIN = {
  label: 'Jakarta (titik awal SEO lokal)',
  lat: -6.2,
  lng: 106.816666,
  minimumRadiusKm: 25,
  note:
    'Lokasi browser tidak aktif, jadi GeoSeek memakai Jakarta sebagai titik awal default untuk keyword local SEO.',
};

const LOCAL_SEO_QUERY_PHRASES = [
  'maps',
  'google maps',
  'maps google',
  'peta',
  'google earth',
  'waze',
  'street view',
  'google maps satellite',
  'peta indonesia',
  'cuaca',
  'cuaca hari ini',
  'prakiraan cuaca',
  'bmkg',
  'ramalan cuaca',
  'cuaca esok hari',
  'terdekat',
  'dekat saya',
  'near me',
  'cafe',
  'restoran',
  'kuliner',
  'rumah makan',
  'tempat makan',
  'warkop',
  'kedai kopi',
  'hotel',
  'penginapan',
  'villa',
  'homestay',
  'kos',
  'kost',
  'kontrakan',
  'apartemen',
  'pom bensin',
  'spbu',
  'pertamini',
  'bengkel',
  'tambal ban',
  'atm',
  'bank',
  'minimarket',
  'indomaret',
  'alfamart',
  'supermarket',
  'toko kelontong',
  'pasar',
  'mall',
  'pusat perbelanjaan',
  'toko baju',
  'toko buku',
  'toko obat',
  'apotek',
  'rumah sakit',
  'puskesmas',
  'klinik',
  'dokter',
  'dokter gigi',
  'bidan',
  'salon',
  'barbershop',
  'pangkas rambut',
  'spa',
  'pijat',
  'gym',
  'fitness',
  'kolam renang',
  'lapangan futsal',
  'taman',
  'tempat wisata',
  'wisata alam',
  'pantai',
  'alun alun',
  'museum',
  'kebun binatang',
  'masjid',
  'mushola',
  'gereja',
  'pura',
  'vihara',
  'sekolah',
  'kampus',
  'universitas',
  'pesantren',
  'lpk',
  'tempat les',
  'stasiun',
  'halte',
  'terminal',
  'bandara',
  'band',
];

const GENERIC_MAP_QUERY_TOKENS = new Set([
  'maps',
  'map',
  'peta',
  'navigasi',
  'waze',
  'earth',
  'satellite',
  'satelit',
  'street',
  'view',
]);

const WEATHER_QUERY_TOKENS = new Set([
  'cuaca',
  'prakiraan',
  'ramalan',
  'bmkg',
]);

const QUERY_LOCATION_SOFT_TOKENS = new Set(
  QUERY_LOCATION_ORIGINS.flatMap((origin) =>
    origin.aliases.flatMap((alias) => normalizeText(alias).split(/\s+/)),
  ),
);

const SOFT_QUERY_TOKENS = new Set([
  ...QUERY_LOCATION_SOFT_TOKENS,
  'aesthetic',
  'aestetik',
  'bagus',
  'banyak',
  'barat',
  'besok',
  'bintang',
  'esok',
  'fresh',
  'google',
  'hari',
  'hemat',
  'indonesia',
  'kekinian',
  'low',
  'malam',
  'modern',
  'murah',
  'pedas',
  'pusat',
  'rendah',
  'satelit',
  'satellite',
  'segar',
  'sehat',
  'selatan',
  'simpel',
  'simple',
  'timur',
  'unik',
  'utara',
  'viral',
]);

const isGenericMapSearchQuery = (query) =>
  normalizeText(query)
    .split(/\s+/)
    .some((token) => GENERIC_MAP_QUERY_TOKENS.has(token));

const isWeatherSearchQuery = (query) =>
  normalizeText(query)
    .split(/\s+/)
    .some((token) => WEATHER_QUERY_TOKENS.has(token));

const findQueryLocationOrigin = (query) => {
  const normalized = normalizeText(query);

  if (!normalized) {
    return null;
  }

  return QUERY_LOCATION_ORIGINS.flatMap((origin) =>
    origin.aliases.map((alias) => ({
      ...origin,
      matched_keyword: alias,
      normalized_alias: normalizeText(alias),
    })),
  )
    .filter(
      (origin) =>
        origin.normalized_alias && normalized.includes(origin.normalized_alias),
    )
    .sort((a, b) => b.normalized_alias.length - a.normalized_alias.length)[0];
};

const isLocalSeoSearchQuery = (query) => {
  const normalized = normalizeText(query);

  if (!normalized) {
    return false;
  }

  return LOCAL_SEO_QUERY_PHRASES.some((phrase) =>
    normalized.includes(normalizeText(phrase)),
  );
};

const buildSearchOriginContext = (lat, lng, query) => {
  if (lat !== null && lng !== null) {
    return {
      origin: { lat, lng },
      meta: {
        lat,
        lng,
        label: 'Lokasi saya',
        source: 'browser',
      },
    };
  }

  const queryOrigin = findQueryLocationOrigin(query);

  if (queryOrigin) {
    return {
      origin: { lat: queryOrigin.lat, lng: queryOrigin.lng },
      meta: {
        lat: queryOrigin.lat,
        lng: queryOrigin.lng,
        label: queryOrigin.label,
        source: 'query_keyword',
        matched_keyword: queryOrigin.matched_keyword,
      },
      minimumRadiusKm: queryOrigin.minimumRadiusKm,
    };
  }

  if (isLocalSeoSearchQuery(query)) {
    return {
      origin: {
        lat: DEFAULT_LOCAL_SEO_ORIGIN.lat,
        lng: DEFAULT_LOCAL_SEO_ORIGIN.lng,
      },
      meta: {
        lat: DEFAULT_LOCAL_SEO_ORIGIN.lat,
        lng: DEFAULT_LOCAL_SEO_ORIGIN.lng,
        label: DEFAULT_LOCAL_SEO_ORIGIN.label,
        source: 'default_local_keyword',
        note: DEFAULT_LOCAL_SEO_ORIGIN.note,
      },
      minimumRadiusKm: DEFAULT_LOCAL_SEO_ORIGIN.minimumRadiusKm,
    };
  }

  const origin = requireSearchOrigin(lat, lng);

  return {
    origin,
    meta: {
      lat: origin.lat,
      lng: origin.lng,
      label: 'Lokasi saya',
      source: 'browser',
    },
  };
};

const parseRadiusFromQueryText = (query) => {
  const normalized = normalizeText(query);
  const radiusMatch =
    normalized.match(/(?:dalam\s+)?radius\s+(\d+(?:[.,]\d+)?)\s*km/) ||
    normalized.match(/\b(\d+(?:[.,]\d+)?)\s*km\b/);

  if (!radiusMatch) {
    return null;
  }

  const number = Number(String(radiusMatch[1]).replace(',', '.'));
  if (!Number.isFinite(number) || number <= 0) {
    return null;
  }

  return Math.min(number, GLOBAL_RADIUS_KM);
};

const hasAnyPhrase = (text, phrases) =>
  phrases.some((phrase) => text.includes(phrase));

const analyzeHyperlocalIntent = (query) => {
  const normalized = normalizeText(query);

  return {
    nearby: hasAnyPhrase(normalized, [
      'terdekat',
      'dekat saya',
      'near me',
      'sekitar saya',
      'lokasi terdekat',
      'dalam radius',
      'radius',
    ]),
    openNow: hasAnyPhrase(normalized, [
      'buka sekarang',
      'buka sek',
      'sedang buka',
    ]),
    twentyFourHour:
      /\b24\s*jam\b/.test(normalized) || normalized.includes('24jam'),
    highestRating: hasAnyPhrase(normalized, [
      'rating tertinggi',
      'terbaik',
      'bintang tertinggi',
    ]),
    popular: hasAnyPhrase(normalized, [
      'populer',
      'paling ramai',
      'ramai',
      'trafik tertinggi',
    ]),
    cheap: hasAnyPhrase(normalized, ['paling murah', 'termurah', 'murah']),
    bestSeller: hasAnyPhrase(normalized, ['terlaris', 'best seller', 'laris']),
    newest: hasAnyPhrase(normalized, [
      'terbaru',
      'baru update',
      'update terbaru',
    ]),
    recommended: hasAnyPhrase(normalized, [
      'direkomendasikan',
      'recommended',
      'rekomendasi',
    ]),
    verified: hasAnyPhrase(normalized, ['terverifikasi', 'verified']),
    requestedRadiusKm: parseRadiusFromQueryText(query),
  };
};

const FUEL_QUERY_TOKENS = new Set([
  'spbu',
  'bbm',
  'bensin',
  'pom',
  'pertamina',
  'shell',
  'vivo',
  'fuel',
  'gas',
]);

const isFuelSearchQuery = (query) => {
  const normalized = normalizeText(query);

  if (!normalized) {
    return false;
  }

  if (normalized.includes('pom bensin') || normalized.includes('gas station')) {
    return true;
  }

  return normalized.split(/\s+/).some((token) => FUEL_QUERY_TOKENS.has(token));
};

const tokenizeQuery = (value) =>
  normalizeText(value)
    .split(/\s+/)
    .filter((token) => token.length > 1 && !STOP_WORDS.has(token));

const includesToken = (text, token) => {
  if (!text || !token) {
    return false;
  }

  if (token.includes(' ')) {
    return text.includes(token);
  }

  const words = text.split(/\s+/).filter(Boolean);
  return words.some(
    (word) =>
      word === token ||
      (token.length >= 4 && word.startsWith(token)) ||
      (token.length >= 5 && word.includes(token)),
  );
};

const scoreField = (field, token, weight) =>
  includesToken(field, token) ? weight : 0;

const isAvailableOffering = (offering) =>
  ['in_stock', 'limited', 'by_request'].includes(offering.stock_status);

const buildOfferingText = (offerings) =>
  offerings
    .map((offering) =>
      [
        offering.name,
        offering.description,
        offering.offering_type === 'service'
          ? 'jasa layanan service servis booking'
          : 'produk barang stok ready marketplace',
        STOCK_STATUS_LABELS[offering.stock_status],
      ]
        .filter(Boolean)
        .join(' '),
    )
    .join(' ');

const scoreOfferingTokens = (
  offerings,
  tokens,
  tokenWeight,
  availableWeight,
) => {
  let score = 0;

  offerings.forEach((offering) => {
    const offeringText = normalizeText(
      [
        offering.name,
        offering.description,
        offering.offering_type,
        STOCK_STATUS_LABELS[offering.stock_status],
      ]
        .filter(Boolean)
        .join(' '),
    );
    const matches = tokens.filter((token) =>
      includesToken(offeringText, token),
    ).length;

    if (!matches) {
      return;
    }

    score += matches * tokenWeight;

    if (isAvailableOffering(offering)) {
      score += availableWeight;
    }

    if (offering.is_verified) {
      score += 5;
    }

    if (offering.stock_status === 'out_of_stock') {
      score -= 25;
    }
  });

  return score;
};

const scoreTokenInSearchFields = (fields, token, weights) =>
  scoreField(fields.name, token, weights.name) +
  scoreField(fields.category, token, weights.category) +
  scoreField(fields.offerings, token, weights.offerings) +
  scoreField(fields.description, token, weights.description) +
  scoreField(fields.location, token, weights.location);

const calculateRawRelevanceScore = (place, query) => {
  const originalTokens = tokenizeQuery(query);
  const requiredTokens = originalTokens.filter(
    (token) => !SOFT_QUERY_TOKENS.has(token),
  );

  if (!originalTokens.length) {
    return 62 + Math.min(calculateInventoryScore(place) * 0.18, 18);
  }

  const normalizedQuery = normalizeText(query);
  const offerings = Array.isArray(place.offerings) ? place.offerings : [];
  const fields = {
    name: normalizeText(place.name),
    category: normalizeText(
      [place.category?.name, place.category?.slug, place.category?.description]
        .filter(Boolean)
        .join(' '),
    ),
    description: normalizeText(
      [place.short_description, place.full_description]
        .filter(Boolean)
        .join(' '),
    ),
    location: normalizeText(
      [place.address, place.city, place.province].filter(Boolean).join(' '),
    ),
    offerings: normalizeText(buildOfferingText(offerings)),
  };

  const requiresCulinaryMatch = originalTokens.some((token) =>
    STRICT_CULINARY_QUERY_TOKENS.has(token),
  );

  if (requiresCulinaryMatch) {
    const culinaryGuardText = [fields.name, fields.category, fields.offerings]
      .filter(Boolean)
      .join(' ');

    if (
      !CULINARY_GUARD_TERMS.some((term) =>
        includesToken(culinaryGuardText, term),
      )
    ) {
      return 0;
    }
  }

  const exactWeights = {
    name: 22,
    category: 18,
    offerings: 26,
    description: 10,
    location: 4,
  };
  const synonymWeights = {
    name: 9,
    category: 7,
    offerings: 11,
    description: 4,
    location: 2,
  };

  let score = 0;

  if (normalizedQuery) {
    score += scoreField(fields.name, normalizedQuery, 80);
    score += scoreField(fields.category, normalizedQuery, 70);
    score += scoreField(fields.offerings, normalizedQuery, 86);
    score += scoreField(fields.description, normalizedQuery, 46);
    score += scoreField(fields.location, normalizedQuery, 16);
  }

  let originalMatches = 0;
  const matchedSearchTerms = new Set();

  originalTokens.forEach((token) => {
    const tokenScore = scoreTokenInSearchFields(fields, token, exactWeights);

    if (tokenScore > 0) {
      originalMatches += 1;
      matchedSearchTerms.add(token);
      score += tokenScore;
    }
  });

  score += scoreOfferingTokens(offerings, originalTokens, 9, 12);

  let synonymScore = 0;
  const usedSynonyms = new Set();

  originalTokens.forEach((originalToken) => {
    let tokenSynonymScore = 0;

    (SYNONYMS[originalToken] || []).forEach((token) => {
      if (usedSynonyms.has(token)) {
        return;
      }

      usedSynonyms.add(token);
      tokenSynonymScore += scoreTokenInSearchFields(
        fields,
        token,
        synonymWeights,
      );
      tokenSynonymScore += scoreOfferingTokens(offerings, [token], 4, 5);
    });

    if (tokenSynonymScore > 0) {
      matchedSearchTerms.add(originalToken);
      synonymScore += tokenSynonymScore;
    }
  });

  if (
    requiredTokens.length > 0 &&
    requiredTokens.some((token) => !matchedSearchTerms.has(token))
  ) {
    return 0;
  }

  if (originalMatches === 0) {
    if (synonymScore < 16) {
      return 0;
    }

    score = synonymScore * 0.72;
  } else {
    score += synonymScore;
  }

  if (score <= 0) {
    return 0;
  }

  if (
    (requiredTokens.length > 0 &&
      requiredTokens.every((token) => matchedSearchTerms.has(token))) ||
    (!requiredTokens.length && matchedSearchTerms.size > 0)
  ) {
    score += 24;
  }

  return score;
};

const calculateInventoryScore = (place) => {
  const offerings = Array.isArray(place.offerings) ? place.offerings : [];
  const availableOfferings = offerings.filter(isAvailableOffering).length;
  const verifiedOfferings = offerings.filter(
    (offering) => offering.is_verified,
  ).length;
  const products = offerings.filter(
    (offering) => offering.offering_type === 'product',
  ).length;
  const services = offerings.filter(
    (offering) => offering.offering_type === 'service',
  ).length;

  return clampScore(
    availableOfferings * 12 +
      verifiedOfferings * 8 +
      products * 3 +
      services * 3,
  );
};

const calculateIntentBoost = (place, intent, radiusKm) => {
  let boost = 0;

  if (intent.openNow && place.live_status?.status === 'open') {
    boost += 34;
  }

  if (intent.twentyFourHour && place.live_status?.status === 'open') {
    boost += 16;
  }

  if (
    intent.nearby &&
    place.distance_km !== null &&
    place.distance_km !== undefined
  ) {
    const safeRadius =
      radiusKm || intent.requestedRadiusKm || DEFAULT_RADIUS_KM;
    boost += clampScore(18 * (1 - Math.min(place.distance_km / safeRadius, 1)));
  }

  if (intent.highestRating && Number(place.rating_average || 0) >= 4.5) {
    boost += 24;
  }

  if (intent.popular || intent.bestSeller) {
    boost += clampScore(Math.log10(Number(place.rating_count || 0) + 1) * 12);
  }

  if (intent.cheap) {
    if (place.price_level === 'budget') {
      boost += 26;
    } else if (place.price_level === 'midrange') {
      boost += 12;
    }
  }

  if (intent.newest) {
    boost += calculateActivityComponent(place) * 0.22;
  }

  if (intent.recommended) {
    boost += 14;
  }

  if (intent.verified && place.is_verified) {
    boost += 28;
  }

  return boost;
};

const calculateRelevanceComponent = (place, query, radiusKm) => {
  const intent = analyzeHyperlocalIntent(query);
  const semanticTokens = tokenizeQuery(query);
  const rawRelevance = calculateRawRelevanceScore(place, query);

  if (rawRelevance <= 0 && semanticTokens.length > 0) {
    return 0;
  }

  const raw = rawRelevance + calculateIntentBoost(place, intent, radiusKm);

  if (raw <= 0) {
    return 0;
  }

  return clampScore(raw * 0.72 + calculateInventoryScore(place) * 0.22);
};

const calculateDistanceComponent = (place, radiusKm) => {
  if (place.distance_km === null || place.distance_km === undefined) {
    return 58;
  }

  const safeRadius = radiusKm || DEFAULT_RADIUS_KM;
  if (safeRadius >= GLOBAL_RADIUS_KM) {
    return clampScore(100 - Math.min(place.distance_km / 100, 80));
  }

  return clampScore(100 * (1 - Math.min(place.distance_km / safeRadius, 1)));
};

const calculateReputationComponent = (place) => {
  const rating = Number(place.rating_average || 0);
  const ratingCount = Number(place.rating_count || 0);
  const ratingScore = clampScore((rating / 5) * 84);
  const volumeScore = clampScore(Math.log10(ratingCount + 1) * 10);
  const verifiedScore = place.is_verified ? 8 : 0;

  return clampScore(ratingScore + volumeScore + verifiedScore);
};

const getMostRecentActivityDate = (place) => {
  const dates = [];

  if (place.updatedAt) {
    dates.push(new Date(place.updatedAt));
  }

  (place.offerings || []).forEach((offering) => {
    if (offering.last_stock_update) {
      dates.push(new Date(offering.last_stock_update));
    }
  });

  return (
    dates
      .filter((date) => !Number.isNaN(date.getTime()))
      .sort((a, b) => b.getTime() - a.getTime())[0] || null
  );
};

const calculateActivityComponent = (place) => {
  const latestDate = getMostRecentActivityDate(place);

  if (!latestDate) {
    return 42;
  }

  const days = (Date.now() - latestDate.getTime()) / (1000 * 60 * 60 * 24);

  if (days <= 1) return 100;
  if (days <= 7) return 92;
  if (days <= 30) return 76;
  if (days <= 90) return 58;
  return 40;
};

const calculateInteractionComponent = (place) => {
  const ratingCount = Number(place.rating_count || 0);
  const offerings = Array.isArray(place.offerings) ? place.offerings : [];
  const availableOfferings = offerings.filter(isAvailableOffering).length;
  const contactScore =
    [
      place.phone_number,
      place.whatsapp_number,
      place.website_url,
      place.google_maps_url,
    ].filter(Boolean).length * 5;
  const engagementScore = Math.log10(ratingCount + 1) * 26;
  const inventoryScore = Math.min(availableOfferings * 4, 18);

  return clampScore(contactScore + engagementScore + inventoryScore);
};

const calculateGeoScore = (place, query, radiusKm) => {
  const components = {
    relevance: calculateRelevanceComponent(place, query, radiusKm),
    distance: calculateDistanceComponent(place, radiusKm),
    reputation: calculateReputationComponent(place),
    activity: calculateActivityComponent(place),
    interaction: calculateInteractionComponent(place),
  };
  const weighted =
    (components.relevance * GEO_SCORE_FORMULA.relevance +
      components.distance * GEO_SCORE_FORMULA.distance +
      components.reputation * GEO_SCORE_FORMULA.reputation +
      components.activity * GEO_SCORE_FORMULA.activity +
      components.interaction * GEO_SCORE_FORMULA.interaction) /
    100;

  return {
    value: Number(clampScore(weighted).toFixed(2)),
    components: Object.fromEntries(
      Object.entries(components).map(([key, value]) => [
        key,
        Number(value.toFixed(2)),
      ]),
    ),
    formula: GEO_SCORE_FORMULA,
  };
};

const compareDistance = (a, b) => {
  if (a.distance_km !== null && b.distance_km !== null)
    return a.distance_km - b.distance_km;
  if (a.distance_km !== null) return -1;
  if (b.distance_km !== null) return 1;
  return 0;
};

const getDistancePriorityRank = (distanceKm) => {
  if (distanceKm === null || distanceKm === undefined) return Number.MAX_SAFE_INTEGER;
  if (distanceKm <= 1) return 0;
  if (distanceKm <= 5) return 1;
  if (distanceKm <= 10) return 2;
  if (distanceKm <= 20) return 3;
  return 4;
};

const compareDistancePriority = (a, b) => {
  const rankDiff = getDistancePriorityRank(a.distance_km) - getDistancePriorityRank(b.distance_km);
  if (rankDiff !== 0) return rankDiff;

  return compareDistance(a, b);
};

const dedupePlacesById = (places) => {
  const placeMap = new Map();

  places.filter(Boolean).forEach((place) => {
    const key = place.id || `${place.external_source || 'local'}-${place.name}`;
    const existingPlace = placeMap.get(key);

    if (!existingPlace || compareDistance(place, existingPlace) < 0) {
      placeMap.set(key, place);
    }
  });

  return Array.from(placeMap.values());
};

const compareLatestActivity = (a, b) => {
  const dateA = getMostRecentActivityDate(a);
  const dateB = getMostRecentActivityDate(b);

  if (dateA && dateB) {
    return dateB.getTime() - dateA.getTime();
  }

  if (dateA) return -1;
  if (dateB) return 1;
  return 0;
};

const compareAveragePrice = (a, b) => {
  const priceA = Number(a.average_price || 0);
  const priceB = Number(b.average_price || 0);

  if (priceA && priceB) {
    return priceA - priceB;
  }

  if (priceA) return -1;
  if (priceB) return 1;
  return 0;
};

const sortScoredPlaces = (a, b, hasQuery, intent = {}) => {
  const distancePriorityDiff = compareDistancePriority(a.place, b.place);
  if (distancePriorityDiff !== 0) return distancePriorityDiff;

  if (intent.openNow || intent.twentyFourHour) {
    const openDiff =
      Number(b.place.live_status?.status === 'open') -
      Number(a.place.live_status?.status === 'open');
    if (openDiff !== 0) return openDiff;
  }

  if (intent.verified) {
    const verifiedDiff =
      Number(b.place.is_verified) - Number(a.place.is_verified);
    if (verifiedDiff !== 0) return verifiedDiff;
  }

  if (intent.highestRating) {
    const ratingDiff =
      (b.place.rating_average || 0) - (a.place.rating_average || 0);
    if (Math.abs(ratingDiff) >= 0.05) return ratingDiff;
  }

  if (intent.popular || intent.bestSeller) {
    const popularityDiff =
      (b.place.rating_count || 0) - (a.place.rating_count || 0);
    if (popularityDiff !== 0) return popularityDiff;
  }

  if (intent.cheap) {
    const priceDiff = compareAveragePrice(a.place, b.place);
    if (priceDiff !== 0) return priceDiff;
  }

  if (intent.newest) {
    const latestDiff = compareLatestActivity(a.place, b.place);
    if (latestDiff !== 0) return latestDiff;
  }

  if (intent.nearby) {
    const distanceDiff = compareDistance(a.place, b.place);
    if (distanceDiff !== 0) return distanceDiff;
  }

  if (hasQuery) {
    const relevanceDiff =
      b.geoScore.components.relevance - a.geoScore.components.relevance;
    if (Math.abs(relevanceDiff) >= 4) return relevanceDiff;
  }

  const geoScoreDiff = b.geoScore.value - a.geoScore.value;

  if (Math.abs(geoScoreDiff) > (hasQuery ? 2 : 5)) {
    return geoScoreDiff;
  }

  const ratingDiff =
    (b.place.rating_average || 0) - (a.place.rating_average || 0);
  if (Math.abs(ratingDiff) >= 0.2) {
    return ratingDiff;
  }

  const distanceDiff = compareDistance(a.place, b.place);
  if (distanceDiff !== 0) {
    return distanceDiff;
  }

  if (Boolean(a.place.is_verified) !== Boolean(b.place.is_verified)) {
    return a.place.is_verified ? -1 : 1;
  }

  return String(a.place.name || '').localeCompare(String(b.place.name || ''));
};

const getLocalHour = () => {
  try {
    const value = new Intl.DateTimeFormat('en-US', {
      hour: 'numeric',
      hour12: false,
      timeZone: 'Asia/Jakarta',
    }).format(new Date());

    return Number(value);
  } catch (err) {
    console.error('Gagal membaca timezone Asia/Jakarta untuk live status', err);
    return (new Date().getUTCHours() + 7) % 24;
  }
};

const isBetweenHour = (hour, start, end) => hour >= start && hour < end;

const calculateLiveStatus = (place) => {
  const hour = getLocalHour();
  const slug = place.category?.slug || '';
  let open = isBetweenHour(hour, 8, 21);
  let busy = false;

  if (slug.includes('cafe')) {
    open = isBetweenHour(hour, 7, 22);
    busy =
      isBetweenHour(hour, 7, 9) ||
      isBetweenHour(hour, 12, 14) ||
      isBetweenHour(hour, 19, 21);
  } else if (slug.includes('bengkel')) {
    open = isBetweenHour(hour, 8, 17);
    busy = isBetweenHour(hour, 9, 11) || isBetweenHour(hour, 14, 16);
  } else if (slug.includes('interior')) {
    open = isBetweenHour(hour, 9, 18);
    busy = isBetweenHour(hour, 10, 12) || isBetweenHour(hour, 15, 17);
  }

  const activityDate = getMostRecentActivityDate(place);
  const updatedToday = activityDate
    ? (Date.now() - activityDate.getTime()) / (1000 * 60 * 60 * 24) <= 1
    : false;

  return {
    status: open ? 'open' : 'closed',
    label: open ? 'Buka sekarang' : 'Tutup sekarang',
    crowd: open ? (busy ? 'Ramai' : 'Sepi') : 'Tutup',
    updated_label: updatedToday ? 'Update stok hari ini' : 'Update berkala',
    source: 'Simulasi live search MVP berbasis jam lokal dan aktivitas data.',
  };
};

const getRadiusZone = (distanceKm, selectedRadiusKm) => {
  const value =
    distanceKm === null || distanceKm === undefined
      ? selectedRadiusKm
      : distanceKm;
  const zone =
    RADIUS_ZONES.find((item) => value <= item.value) ||
    RADIUS_ZONES[RADIUS_ZONES.length - 1];

  return zone;
};

const toPublicOffering = (offering) => ({
  id: offering.id,
  name: offering.name,
  description: offering.description,
  offering_type: offering.offering_type,
  price: toNumber(offering.price),
  stock_status: offering.stock_status,
  stock_label: STOCK_STATUS_LABELS[offering.stock_status] || 'Info stok',
  stock_quantity: offering.stock_quantity,
  is_verified: Boolean(offering.is_verified),
  last_stock_update: offering.last_stock_update,
});

const summarizeOfferings = (offerings) => {
  const activeOfferings = Array.isArray(offerings) ? offerings : [];
  const availableOfferings = activeOfferings.filter(isAvailableOffering);

  return {
    total: activeOfferings.length,
    available: availableOfferings.length,
    products: activeOfferings.filter(
      (offering) => offering.offering_type === 'product',
    ).length,
    services: activeOfferings.filter(
      (offering) => offering.offering_type === 'service',
    ).length,
    verified: activeOfferings.filter((offering) => offering.is_verified).length,
    top_available: availableOfferings.slice(0, 3),
  };
};

const calculateDistanceKm = (lat1, lon1, lat2, lon2) => {
  const earthRadiusKm = 6371;
  const toRadians = (value) => (value * Math.PI) / 180;
  const dLat = toRadians(lat2 - lat1);
  const dLon = toRadians(lon2 - lon1);
  const a =
    Math.sin(dLat / 2) * Math.sin(dLat / 2) +
    Math.cos(toRadians(lat1)) *
      Math.cos(toRadians(lat2)) *
      Math.sin(dLon / 2) *
      Math.sin(dLon / 2);

  return earthRadiusKm * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
};

const OVERPASS_ENDPOINTS = [
  'https://z.overpass-api.de/api/interpreter',
  'https://maps.mail.ru/osm/tools/overpass/api/interpreter',
  'https://overpass-api.de/api/interpreter',
];

const OSM_SEARCH_PROFILES = [
  {
    id: 'fuel',
    matchTerms: Array.from(FUEL_QUERY_TOKENS),
    filters: [{ key: 'amenity', value: 'fuel' }],
    defaultName: 'SPBU / Pom Bensin',
    shortDescription: 'SPBU/pom bensin terdekat dari data OpenStreetMap.',
    fullDescription:
      'Data SPBU diambil dari OpenStreetMap dan diurutkan berdasarkan jarak dari lokasi Anda.',
    keywords:
      'spbu bbm bensin pom pertamina shell vivo fuel gas station bahan bakar',
    category: {
      id: 'openstreetmap-fuel',
      name: 'SPBU / Pom Bensin',
      slug: 'spbu-pom-bensin',
      color_hex: '#D72638',
      description: 'Stasiun pengisian bahan bakar dari OpenStreetMap.',
    },
    offering: {
      name: 'BBM / bahan bakar',
      description: 'Ketersediaan mengikuti data lokasi SPBU.',
      offering_type: 'product',
    },
    maxRadiusKm: 50,
  },
  {
    id: 'cafe',
    matchTerms: ['cafe', 'kafe', 'kopi', 'coffee', 'kedai'],
    filters: [
      { key: 'amenity', value: 'cafe' },
      { key: 'shop', value: 'coffee' },
    ],
    defaultName: 'Cafe / Kedai Kopi',
    shortDescription:
      'Cafe, kedai kopi, atau tempat nongkrong dari OpenStreetMap.',
    fullDescription:
      'Data cafe/kopi diambil dari OpenStreetMap dan diurutkan berdasarkan jarak dari lokasi Anda.',
    keywords: 'cafe kafe kopi coffee kedai nongkrong minum minuman kuliner',
    category: {
      id: 'openstreetmap-cafe',
      name: 'Cafe / Kopi',
      slug: 'cafe-kopi',
      color_hex: '#8B5A2B',
      description: 'Cafe dan kedai kopi dari OpenStreetMap.',
    },
    offering: {
      name: 'Menu cafe / kopi',
      description: 'Menu dan layanan cafe mengikuti informasi lokasi.',
      offering_type: 'service',
    },
    maxRadiusKm: 25,
  },
  {
    id: 'restaurant',
    matchTerms: [
      'restoran',
      'restaurant',
      'kuliner',
      'makan',
      'makanan',
      'warung',
      'nasi',
      'food',
    ],
    filters: [
      { key: 'amenity', value: 'restaurant' },
      { key: 'amenity', value: 'fast_food' },
      { key: 'amenity', value: 'food_court' },
      { key: 'shop', value: 'bakery' },
    ],
    defaultName: 'Restoran / Kuliner',
    shortDescription:
      'Tempat makan, restoran, warung, atau kuliner dari OpenStreetMap.',
    fullDescription:
      'Data restoran/kuliner diambil dari OpenStreetMap dan diurutkan berdasarkan jarak dari lokasi Anda.',
    keywords:
      'restoran restaurant kuliner makan makanan warung nasi food court fast food roti bakery kue',
    category: {
      id: 'openstreetmap-restaurant',
      name: 'Restoran / Kuliner',
      slug: 'restoran-kuliner',
      color_hex: '#F26A4B',
      description: 'Tempat makan dan kuliner dari OpenStreetMap.',
    },
    offering: {
      name: 'Makanan / minuman',
      description: 'Menu dan layanan kuliner mengikuti informasi lokasi.',
      offering_type: 'service',
    },
    maxRadiusKm: 25,
  },
  {
    id: 'automotive',
    matchTerms: [
      'bengkel',
      'service',
      'servis',
      'mobil',
      'otomotif',
      'automotif',
      'auto',
      'oli',
      'ban',
      'tambal',
      'cuci',
      'spooring',
      'balancing',
    ],
    filters: [
      { key: 'shop', value: 'car_repair' },
      { key: 'shop', value: 'car_parts' },
      { key: 'shop', value: 'tyres' },
      { key: 'shop', value: 'car' },
      { key: 'amenity', value: 'car_wash' },
    ],
    defaultName: 'Bengkel / Otomotif',
    shortDescription:
      'Bengkel, layanan otomotif, cuci mobil, atau toko ban dari OpenStreetMap.',
    fullDescription:
      'Data bengkel/otomotif diambil dari OpenStreetMap dan diurutkan berdasarkan jarak dari lokasi Anda.',
    keywords:
      'bengkel service servis mobil kendaraan otomotif automotif auto oli ban tambal spooring balancing cuci mobil car repair tyres',
    category: {
      id: 'openstreetmap-automotive',
      name: 'Bengkel / Otomotif',
      slug: 'bengkel-otomotif',
      color_hex: '#2563EB',
      description: 'Bengkel dan layanan otomotif dari OpenStreetMap.',
    },
    offering: {
      name: 'Service kendaraan',
      description: 'Layanan bengkel/otomotif mengikuti informasi lokasi.',
      offering_type: 'service',
    },
    maxRadiusKm: 25,
  },
  {
    id: 'pharmacy',
    matchTerms: ['apotek', 'apotik', 'farmasi', 'obat', 'chemist'],
    filters: [
      { key: 'amenity', value: 'pharmacy' },
      { key: 'healthcare', value: 'pharmacy' },
      { key: 'shop', value: 'chemist' },
    ],
    defaultName: 'Apotek / Farmasi',
    shortDescription: 'Apotek atau farmasi dari OpenStreetMap.',
    fullDescription:
      'Data apotek/farmasi diambil dari OpenStreetMap dan diurutkan berdasarkan jarak dari lokasi Anda.',
    keywords: 'apotek apotik farmasi obat kesehatan herbal chemist pharmacy',
    category: {
      id: 'openstreetmap-pharmacy',
      name: 'Apotek / Farmasi',
      slug: 'apotek-farmasi',
      color_hex: '#0F766E',
      description: 'Apotek dan farmasi dari OpenStreetMap.',
    },
    offering: {
      name: 'Obat / produk kesehatan',
      description: 'Ketersediaan obat mengikuti informasi lokasi.',
      offering_type: 'product',
    },
    maxRadiusKm: 25,
  },
  {
    id: 'healthcare',
    matchTerms: [
      'klinik',
      'clinic',
      'dokter',
      'doctor',
      'rumah sakit',
      'sakit',
      'rs',
      'hospital',
      'kesehatan',
      'gigi',
      'dentist',
    ],
    filters: [
      { key: 'amenity', value: 'clinic' },
      { key: 'amenity', value: 'hospital' },
      { key: 'amenity', value: 'doctors' },
      { key: 'amenity', value: 'dentist' },
      { key: 'healthcare', value: 'clinic' },
      { key: 'healthcare', value: 'hospital' },
      { key: 'healthcare', value: 'doctor' },
      { key: 'healthcare', value: 'dentist' },
    ],
    defaultName: 'Klinik / Kesehatan',
    shortDescription:
      'Klinik, dokter, rumah sakit, atau layanan kesehatan dari OpenStreetMap.',
    fullDescription:
      'Data fasilitas kesehatan diambil dari OpenStreetMap dan diurutkan berdasarkan jarak dari lokasi Anda.',
    keywords:
      'klinik clinic dokter doctor rumah sakit rs hospital kesehatan medis gigi dentist obat',
    category: {
      id: 'openstreetmap-healthcare',
      name: 'Klinik / Kesehatan',
      slug: 'klinik-kesehatan',
      color_hex: '#087F6D',
      description: 'Fasilitas kesehatan dari OpenStreetMap.',
    },
    offering: {
      name: 'Layanan kesehatan',
      description: 'Layanan kesehatan mengikuti informasi lokasi.',
      offering_type: 'service',
    },
    maxRadiusKm: 50,
  },
  {
    id: 'hotel',
    matchTerms: [
      'hotel',
      'penginapan',
      'hostel',
      'guesthouse',
      'guest house',
      'villa',
      'resort',
      'motel',
      'homestay',
    ],
    filters: [
      { key: 'tourism', value: 'hotel' },
      { key: 'tourism', value: 'guest_house' },
      { key: 'tourism', value: 'hostel' },
      { key: 'tourism', value: 'motel' },
      { key: 'tourism', value: 'apartment' },
      { key: 'tourism', value: 'chalet' },
    ],
    defaultName: 'Hotel / Penginapan',
    shortDescription: 'Hotel, hostel, atau penginapan dari OpenStreetMap.',
    fullDescription:
      'Data hotel/penginapan diambil dari OpenStreetMap dan diurutkan berdasarkan jarak dari lokasi Anda.',
    keywords:
      'hotel penginapan hostel guesthouse guest house villa resort motel homestay travel wisata lodging accommodation',
    category: {
      id: 'openstreetmap-hotel',
      name: 'Hotel / Penginapan',
      slug: 'hotel-penginapan',
      color_hex: '#7C3AED',
      description: 'Hotel dan penginapan dari OpenStreetMap.',
    },
    offering: {
      name: 'Reservasi penginapan',
      description: 'Ketersediaan kamar mengikuti informasi lokasi.',
      offering_type: 'service',
    },
    maxRadiusKm: 50,
  },
  {
    id: 'tourism',
    matchTerms: [
      'wisata',
      'travel',
      'taman',
      'pantai',
      'museum',
      'rekreasi',
      'attraction',
      'tourism',
      'liburan',
    ],
    filters: [
      { key: 'tourism', value: 'attraction' },
      { key: 'tourism', value: 'museum' },
      { key: 'tourism', value: 'gallery' },
      { key: 'tourism', value: 'zoo' },
      { key: 'tourism', value: 'theme_park' },
      { key: 'tourism', value: 'viewpoint' },
      { key: 'leisure', value: 'park' },
      { key: 'leisure', value: 'garden' },
    ],
    defaultName: 'Wisata / Rekreasi',
    shortDescription:
      'Tempat wisata, taman, museum, atau rekreasi dari OpenStreetMap.',
    fullDescription:
      'Data wisata/rekreasi diambil dari OpenStreetMap dan diurutkan berdasarkan jarak dari lokasi Anda.',
    keywords:
      'wisata travel taman pantai museum rekreasi attraction tourism liburan park garden zoo viewpoint',
    category: {
      id: 'openstreetmap-tourism',
      name: 'Wisata / Rekreasi',
      slug: 'wisata-rekreasi',
      color_hex: '#16A34A',
      description: 'Tempat wisata dan rekreasi dari OpenStreetMap.',
    },
    offering: {
      name: 'Info kunjungan',
      description: 'Informasi kunjungan mengikuti data lokasi.',
      offering_type: 'service',
    },
    maxRadiusKm: 50,
  },
  {
    id: 'government-office',
    matchTerms: [
      'kantor',
      'pemerintah',
      'pemerintahan',
      'pemda',
      'instansi',
      'layanan publik',
      'bpn',
      'atr',
      'agraria',
      'pertanahan',
      'camat',
      'kecamatan',
      'kelurahan',
      'desa',
      'samsat',
      'dukcapil',
      'disdukcapil',
      'pajak',
      'kpp',
      'pengadilan',
      'kejaksaan',
      'polisi',
      'polsek',
      'polres',
    ],
    filters: [
      { key: 'office', value: 'government' },
      { key: 'government' },
      { key: 'amenity', value: 'townhall' },
      { key: 'amenity', value: 'courthouse' },
      { key: 'amenity', value: 'police' },
      { key: 'office', value: 'administrative' },
      { key: 'office', value: 'tax' },
    ],
    defaultName: 'Kantor Pemerintahan',
    shortDescription:
      'Kantor pemerintahan, layanan publik, atau instansi dari OpenStreetMap.',
    fullDescription:
      'Data kantor pemerintahan diambil dari OpenStreetMap dan diurutkan berdasarkan jarak dari lokasi Anda.',
    keywords:
      'kantor pemerintah pemerintahan layanan publik instansi pemda administrasi publik',
    category: {
      id: 'openstreetmap-government-office',
      name: 'Kantor Pemerintahan',
      slug: 'kantor-pemerintahan',
      color_hex: '#0F766E',
      description: 'Kantor layanan publik dan pemerintahan dari OpenStreetMap.',
    },
    offering: {
      name: 'Layanan publik',
      description: 'Layanan mengikuti informasi resmi lokasi terkait.',
      offering_type: 'service',
    },
    maxRadiusKm: 100,
  },
  {
    id: 'bumn-office',
    matchTerms: [
      'bumn',
      'bumd',
      'kantor bumn',
      'kantor bumd',
      'kantor pos',
      'pos indonesia',
      'kantor pln',
      'pln',
      'kantor pdam',
      'pdam',
      'kantor telkom',
      'telkom',
      'kantor bpjs',
      'bpjs',
      'kantor bri',
      'bri',
      'kantor bni',
      'bni',
      'bank mandiri',
      'kantor mandiri',
      'mandiri',
      'kantor btn',
      'btn',
      'pegadaian',
      'kantor pegadaian',
      'pertamina',
    ],
    filters: [
      { key: 'amenity', value: 'post_office' },
      { key: 'office', value: 'utility' },
      { key: 'office', value: 'telecommunication' },
      {
        conditions: [
          { key: 'amenity', value: 'fuel' },
          { key: 'brand', value: 'Pertamina' },
        ],
      },
      {
        conditions: [
          { key: 'amenity', value: 'bank' },
          { key: 'brand', value: 'BRI' },
        ],
      },
      {
        conditions: [
          { key: 'amenity', value: 'bank' },
          { key: 'brand', value: 'BNI' },
        ],
      },
      {
        conditions: [
          { key: 'amenity', value: 'bank' },
          { key: 'brand', value: 'Bank Mandiri' },
        ],
      },
      {
        conditions: [
          { key: 'amenity', value: 'bank' },
          { key: 'brand', value: 'BTN' },
        ],
      },
    ],
    defaultName: 'Kantor BUMN / BUMD',
    shortDescription:
      'Kantor BUMN/BUMD, layanan publik perusahaan negara/daerah, atau outlet terkait dari OpenStreetMap.',
    fullDescription:
      'Data kantor BUMN/BUMD diambil dari OpenStreetMap dan diurutkan berdasarkan jarak dari lokasi Anda.',
    keywords:
      'kantor bumn bumd layanan publik perusahaan negara daerah utilitas telekomunikasi perbankan pos energi',
    category: {
      id: 'openstreetmap-bumn-office',
      name: 'Kantor BUMN / BUMD',
      slug: 'kantor-bumn-bumd',
      color_hex: '#155E75',
      description: 'Kantor dan layanan BUMN/BUMD dari OpenStreetMap.',
    },
    offering: {
      name: 'Layanan BUMN / BUMD',
      description: 'Layanan mengikuti informasi resmi lokasi terkait.',
      offering_type: 'service',
    },
    maxRadiusKm: 100,
  },
  {
    id: 'bank-atm',
    matchTerms: ['atm', 'bank', 'tunai', 'uang', 'cash'],
    filters: [
      { key: 'amenity', value: 'atm' },
      { key: 'amenity', value: 'bank' },
    ],
    defaultName: 'ATM / Bank',
    shortDescription: 'ATM atau bank dari OpenStreetMap.',
    fullDescription:
      'Data ATM/bank diambil dari OpenStreetMap dan diurutkan berdasarkan jarak dari lokasi Anda.',
    keywords: 'atm bank tunai uang cash tarik setor pembayaran finansial',
    category: {
      id: 'openstreetmap-bank-atm',
      name: 'ATM / Bank',
      slug: 'atm-bank',
      color_hex: '#0E7490',
      description: 'ATM dan bank dari OpenStreetMap.',
    },
    offering: {
      name: 'Layanan perbankan',
      description: 'Layanan ATM/bank mengikuti informasi lokasi.',
      offering_type: 'service',
    },
    maxRadiusKm: 25,
  },
  {
    id: 'retail',
    matchTerms: [
      'minimarket',
      'supermarket',
      'toko',
      'belanja',
      'pasar',
      'mall',
      'market',
      'store',
      'baju',
      'pakaian',
      'furniture',
      'furnitur',
      'mebel',
      'laundry',
      'salon',
    ],
    filters: [
      { key: 'shop', value: 'convenience' },
      { key: 'shop', value: 'supermarket' },
      { key: 'shop', value: 'mall' },
      { key: 'shop', value: 'department_store' },
      { key: 'shop', value: 'general' },
      { key: 'shop', value: 'variety_store' },
      { key: 'shop', value: 'kiosk' },
      { key: 'shop', value: 'clothes' },
      { key: 'shop', value: 'shoes' },
      { key: 'shop', value: 'electronics' },
      { key: 'shop', value: 'mobile_phone' },
      { key: 'shop', value: 'computer' },
      { key: 'shop', value: 'hardware' },
      { key: 'shop', value: 'furniture' },
      { key: 'shop', value: 'books' },
      { key: 'shop', value: 'stationery' },
      { key: 'shop', value: 'beauty' },
      { key: 'shop', value: 'hairdresser' },
      { key: 'shop', value: 'laundry' },
      { key: 'amenity', value: 'marketplace' },
    ],
    defaultName: 'Toko / Belanja',
    shortDescription:
      'Toko, minimarket, pasar, mall, atau layanan retail dari OpenStreetMap.',
    fullDescription:
      'Data toko/retail diambil dari OpenStreetMap dan diurutkan berdasarkan jarak dari lokasi Anda.',
    keywords:
      'toko belanja minimarket supermarket pasar mall market store baju pakaian fashion furniture furnitur mebel laundry salon produk barang',
    category: {
      id: 'openstreetmap-retail',
      name: 'Toko / Belanja',
      slug: 'toko-belanja',
      color_hex: '#F59E0B',
      description: 'Toko dan retail dari OpenStreetMap.',
    },
    offering: {
      name: 'Produk / layanan toko',
      description: 'Produk dan layanan toko mengikuti informasi lokasi.',
      offering_type: 'product',
    },
    maxRadiusKm: 15,
  },
  {
    id: 'education',
    matchTerms: [
      'sekolah',
      'kampus',
      'universitas',
      'kuliah',
      'pendidikan',
      'kursus',
      'belajar',
      'tk',
      'college',
    ],
    filters: [
      { key: 'amenity', value: 'school' },
      { key: 'amenity', value: 'college' },
      { key: 'amenity', value: 'university' },
      { key: 'amenity', value: 'kindergarten' },
      { key: 'amenity', value: 'language_school' },
      { key: 'office', value: 'educational_institution' },
    ],
    defaultName: 'Sekolah / Pendidikan',
    shortDescription:
      'Sekolah, kampus, atau lembaga pendidikan dari OpenStreetMap.',
    fullDescription:
      'Data pendidikan diambil dari OpenStreetMap dan diurutkan berdasarkan jarak dari lokasi Anda.',
    keywords:
      'sekolah kampus universitas kuliah pendidikan kursus belajar tk college university kindergarten',
    category: {
      id: 'openstreetmap-education',
      name: 'Sekolah / Pendidikan',
      slug: 'sekolah-pendidikan',
      color_hex: '#1D4ED8',
      description: 'Institusi pendidikan dari OpenStreetMap.',
    },
    offering: {
      name: 'Layanan pendidikan',
      description: 'Layanan pendidikan mengikuti informasi lokasi.',
      offering_type: 'service',
    },
    maxRadiusKm: 25,
  },
  {
    id: 'mosque',
    matchTerms: ['masjid', 'mushola', 'musala', 'surau'],
    filters: [
      {
        conditions: [
          { key: 'amenity', value: 'place_of_worship' },
          { key: 'religion', value: 'muslim' },
        ],
      },
    ],
    defaultName: 'Masjid / Mushola',
    shortDescription:
      'Masjid, mushola, atau tempat ibadah muslim dari OpenStreetMap.',
    fullDescription:
      'Data masjid/mushola diambil dari OpenStreetMap dan diurutkan berdasarkan jarak dari lokasi Anda.',
    keywords: 'masjid mushola musala surau ibadah muslim tempat ibadah',
    category: {
      id: 'openstreetmap-mosque',
      name: 'Masjid / Mushola',
      slug: 'masjid-mushola',
      color_hex: '#059669',
      description: 'Tempat ibadah muslim dari OpenStreetMap.',
    },
    offering: {
      name: 'Tempat ibadah',
      description: 'Informasi tempat ibadah mengikuti data lokasi.',
      offering_type: 'service',
    },
    maxRadiusKm: 25,
  },
  {
    id: 'church',
    matchTerms: ['gereja', 'church', 'katedral'],
    filters: [
      {
        conditions: [
          { key: 'amenity', value: 'place_of_worship' },
          { key: 'religion', value: 'christian' },
        ],
      },
    ],
    defaultName: 'Gereja',
    shortDescription: 'Gereja atau tempat ibadah kristiani dari OpenStreetMap.',
    fullDescription:
      'Data gereja diambil dari OpenStreetMap dan diurutkan berdasarkan jarak dari lokasi Anda.',
    keywords:
      'gereja church katedral ibadah kristen katolik protestan tempat ibadah',
    category: {
      id: 'openstreetmap-church',
      name: 'Gereja',
      slug: 'gereja',
      color_hex: '#6366F1',
      description: 'Gereja dari OpenStreetMap.',
    },
    offering: {
      name: 'Tempat ibadah',
      description: 'Informasi tempat ibadah mengikuti data lokasi.',
      offering_type: 'service',
    },
    maxRadiusKm: 25,
  },
  {
    id: 'worship',
    matchTerms: [
      'ibadah',
      'tempat ibadah',
      'pura',
      'vihara',
      'kelenteng',
      'klenteng',
    ],
    filters: [{ key: 'amenity', value: 'place_of_worship' }],
    defaultName: 'Tempat Ibadah',
    shortDescription: 'Tempat ibadah dari OpenStreetMap.',
    fullDescription:
      'Data tempat ibadah diambil dari OpenStreetMap dan diurutkan berdasarkan jarak dari lokasi Anda.',
    keywords:
      'ibadah tempat ibadah masjid mushola gereja pura vihara kelenteng klenteng worship',
    category: {
      id: 'openstreetmap-worship',
      name: 'Tempat Ibadah',
      slug: 'tempat-ibadah',
      color_hex: '#475569',
      description: 'Tempat ibadah dari OpenStreetMap.',
    },
    offering: {
      name: 'Tempat ibadah',
      description: 'Informasi tempat ibadah mengikuti data lokasi.',
      offering_type: 'service',
    },
    maxRadiusKm: 25,
  },
  {
    id: 'fitness',
    matchTerms: ['gym', 'fitness', 'olahraga', 'sport', 'fitnes'],
    filters: [
      { key: 'leisure', value: 'fitness_centre' },
      { key: 'leisure', value: 'sports_centre' },
      { key: 'shop', value: 'sports' },
    ],
    defaultName: 'Gym / Olahraga',
    shortDescription:
      'Gym, fitness center, atau fasilitas olahraga dari OpenStreetMap.',
    fullDescription:
      'Data gym/olahraga diambil dari OpenStreetMap dan diurutkan berdasarkan jarak dari lokasi Anda.',
    keywords: 'gym fitness fitnes olahraga sport sports centre pusat kebugaran',
    category: {
      id: 'openstreetmap-fitness',
      name: 'Gym / Olahraga',
      slug: 'gym-olahraga',
      color_hex: '#DC2626',
      description: 'Gym dan fasilitas olahraga dari OpenStreetMap.',
    },
    offering: {
      name: 'Layanan olahraga',
      description: 'Layanan olahraga mengikuti informasi lokasi.',
      offering_type: 'service',
    },
    maxRadiusKm: 25,
  },
];


const mergeUniqueList = (...lists) =>
  Array.from(new Set(lists.flat().filter(Boolean)));

const mergeUniqueFilters = (...lists) => {
  const seen = new Set();
  const merged = [];

  lists.flat().forEach((filter) => {
    const key = JSON.stringify(filter);

    if (seen.has(key)) {
      return;
    }

    seen.add(key);
    merged.push(filter);
  });

  return merged;
};

const extendOsmProfile = (id, extension) => {
  const profile = OSM_SEARCH_PROFILES.find((item) => item.id === id);

  if (!profile) {
    return;
  }

  profile.matchTerms = mergeUniqueList(profile.matchTerms, extension.matchTerms);
  profile.filters = mergeUniqueFilters(profile.filters, extension.filters || []);
  profile.keywords = [profile.keywords, extension.keywords]
    .filter(Boolean)
    .join(' ');

  if (extension.maxRadiusKm) {
    profile.maxRadiusKm = Math.max(profile.maxRadiusKm || 0, extension.maxRadiusKm);
  }
};

extendOsmProfile('restaurant', {
  matchTerms: [
    'warteg',
    'warung tegal',
    'bakso',
    'soto',
    'ayam goreng',
    'seafood',
    'catering',
    'makanan terdekat',
    'minuman',
    'makanan',
    'warung makan',
  ],
  filters: [
    { key: 'amenity', value: 'cafe' },
    { key: 'shop', value: 'beverages' },
    { key: 'shop', value: 'confectionery' },
  ],
  keywords:
    'warteg warung tegal bakso soto ayam goreng seafood catering makanan minuman restoran kuliner',
  maxRadiusKm: 100,
});

extendOsmProfile('cafe', {
  matchTerms: ['minuman', 'kedai kopi', 'coffee shop', 'cafe terdekat'],
  filters: [
    { key: 'shop', value: 'beverages' },
    { key: 'cuisine', value: 'coffee_shop' },
  ],
  keywords: 'minuman beverage kedai kopi coffee shop',
  maxRadiusKm: 75,
});

extendOsmProfile('automotive', {
  matchTerms: [
    'bengkel motor',
    'bengkel mobil',
    'sparepart',
    'sparepart motor',
    'sparepart mobil',
    'suku cadang',
    'dealer motor',
    'dealer mobil',
    'motor',
    'motorcycle',
  ],
  filters: [
    { key: 'shop', value: 'motorcycle' },
    { key: 'shop', value: 'motorcycle_repair' },
    { key: 'shop', value: 'truck_repair' },
    { key: 'shop', value: 'vehicle_parts' },
  ],
  keywords:
    'bengkel motor bengkel mobil sparepart motor sparepart mobil dealer motor dealer mobil suku cadang',
  maxRadiusKm: 100,
});

extendOsmProfile('pharmacy', {
  matchTerms: ['herbal', 'jamu', 'alat kesehatan', 'alkes', 'toko obat'],
  filters: [
    { key: 'shop', value: 'medical_supply' },
    { key: 'shop', value: 'herbalist' },
  ],
  keywords: 'herbal jamu alat kesehatan alkes toko obat medical supply',
  maxRadiusKm: 100,
});

extendOsmProfile('healthcare', {
  matchTerms: ['laboratorium', 'lab kesehatan', 'dokter gigi', 'alat kesehatan'],
  filters: [
    { key: 'healthcare', value: 'laboratory' },
    { key: 'amenity', value: 'laboratory' },
    { key: 'healthcare', value: 'medical_lab' },
  ],
  keywords: 'laboratorium lab kesehatan dokter gigi medical laboratory alkes',
  maxRadiusKm: 100,
});

extendOsmProfile('retail', {
  matchTerms: [
    'fashion',
    'sepatu',
    'tas',
    'kosmetik',
    'skincare',
    'furniture',
    'peralatan rumah tangga',
    'peralatan dapur',
    'perlengkapan bayi',
    'mainan anak',
    'buku',
    'kebutuhan kantor',
    'percetakan',
    'konveksi',
    'perhiasan',
    'jam tangan',
    'aksesoris',
    'umkm',
    'toko kelontong',
    'home industry',
  ],
  filters: [
    { key: 'shop', value: 'bag' },
    { key: 'shop', value: 'cosmetics' },
    { key: 'shop', value: 'houseware' },
    { key: 'shop', value: 'kitchen' },
    { key: 'shop', value: 'doityourself' },
    { key: 'shop', value: 'building_materials' },
    { key: 'shop', value: 'paint' },
    { key: 'shop', value: 'baby_goods' },
    { key: 'shop', value: 'toys' },
    { key: 'shop', value: 'copyshop' },
    { key: 'shop', value: 'tailor' },
    { key: 'shop', value: 'jewelry' },
    { key: 'shop', value: 'watches' },
    { key: 'shop', value: 'gift' },
    { key: 'shop', value: 'greengrocer' },
  ],
  keywords:
    'fashion sepatu tas kosmetik skincare furniture alat rumah tangga alat dapur mainan buku percetakan konveksi perhiasan aksesoris umkm toko kelontong home industry',
  maxRadiusKm: 100,
});

extendOsmProfile('bank-atm', {
  matchTerms: ['koperasi', 'pegadaian', 'qris', 'e wallet', 'cashback'],
  filters: [
    { key: 'amenity', value: 'money_transfer' },
    { key: 'office', value: 'financial' },
    { key: 'shop', value: 'pawnbroker' },
  ],
  keywords: 'koperasi pegadaian qris e wallet pembayaran keuangan financial',
  maxRadiusKm: 100,
});


extendOsmProfile('restaurant', {
  matchTerms: [
    'restoran terdekat',
    'kuliner terdekat',
    'rumah makan terdekat',
    'tempat makan terdekat',
    'makanan enak',
    'kuliner malam',
    'resep makanan simpel',
    'cemilan unik',
    'jajanan pasar modern',
    'makanan viral murah',
    'ide jualan makanan',
    'makanan pedas',
    'katering',
    'katering sehat',
    'snack',
    'cemilan',
    'jajanan',
  ],
  filters: [
    { key: 'shop', value: 'deli' },
    { key: 'shop', value: 'pastry' },
    { key: 'shop', value: 'greengrocer' },
  ],
  keywords:
    'rumah makan tempat makan makanan enak kuliner malam resep makanan simpel cemilan unik jajanan pasar modern makanan viral murah ide jualan makanan makanan pedas katering catering snack pastry deli',
  maxRadiusKm: 100,
});

extendOsmProfile('cafe', {
  matchTerms: [
    'cafe aesthetic',
    'tempat nongkrong terdekat',
    'warkop terdekat',
    'kedai kopi terdekat',
    'minuman kekinian',
    'resep minuman segar',
    'es kopi susu',
    'franchise minuman',
    'boba drink',
    'boba',
    'warkop',
    'tempat nongkrong',
  ],
  filters: [
    { key: 'amenity', value: 'bar' },
    { key: 'shop', value: 'tea' },
  ],
  keywords:
    'cafe aesthetic tempat nongkrong warkop kedai kopi minuman kekinian resep minuman segar es kopi susu franchise minuman boba drink beverage tea bar',
  maxRadiusKm: 100,
});

extendOsmProfile('fuel', {
  matchTerms: ['pom bensin terdekat', 'spbu terdekat', 'pertamini terdekat'],
  keywords:
    'pom bensin terdekat spbu terdekat pertamini terdekat pertamina shell vivo bp akr gas station maps peta navigasi',
  maxRadiusKm: 100,
});

extendOsmProfile('automotive', {
  matchTerms: [
    'bengkel terdekat',
    'bengkel mobil terdekat',
    'bengkel motor terdekat',
    'tambal ban terdekat',
  ],
  keywords:
    'bengkel terdekat bengkel mobil terdekat bengkel motor terdekat tambal ban terdekat maps peta navigasi',
  maxRadiusKm: 100,
});

extendOsmProfile('bank-atm', {
  matchTerms: [
    'atm terdekat',
    'atm bca terdekat',
    'atm bri terdekat',
    'atm mandiri terdekat',
    'atm bni terdekat',
    'bank terdekat',
    'bca',
    'bri',
    'mandiri',
    'bni',
  ],
  keywords:
    'atm terdekat atm bca atm bri atm mandiri atm bni bank terdekat bca bri mandiri bni maps peta navigasi',
  maxRadiusKm: 100,
});

extendOsmProfile('retail', {
  matchTerms: [
    'minimarket terdekat',
    'indomaret terdekat',
    'alfamart terdekat',
    'supermarket terdekat',
    'toko kelontong terdekat',
    'pasar terdekat',
    'mall terdekat',
    'pusat perbelanjaan terdekat',
    'toko baju terdekat',
    'toko buku terdekat',
  ],
  filters: [
    { key: 'shop', value: 'department_store' },
    { key: 'shop', value: 'mall' },
  ],
  keywords:
    'minimarket indomaret alfamart supermarket toko kelontong pasar mall pusat perbelanjaan toko baju toko buku maps peta navigasi',
  maxRadiusKm: 100,
});

extendOsmProfile('pharmacy', {
  matchTerms: ['toko obat terdekat', 'apotek 24 jam terdekat'],
  keywords:
    'toko obat terdekat apotek 24 jam terdekat apotek farmasi herbal maps peta navigasi',
  maxRadiusKm: 100,
});

extendOsmProfile('healthcare', {
  matchTerms: [
    'puskesmas terdekat',
    'bidan terdekat',
    'dokter terdekat',
    'dokter gigi terdekat',
  ],
  filters: [
    { key: 'amenity', value: 'community_health_centre' },
    { key: 'healthcare', value: 'midwife' },
  ],
  keywords:
    'puskesmas bidan dokter terdekat dokter gigi terdekat community health centre midwife maps peta navigasi',
  maxRadiusKm: 100,
});

extendOsmProfile('hotel', {
  matchTerms: [
    'hotel terdekat',
    'penginapan terdekat',
    'villa terdekat',
    'homestay terdekat',
  ],
  keywords:
    'hotel terdekat penginapan terdekat villa terdekat homestay terdekat maps peta navigasi',
  maxRadiusKm: 100,
});

extendOsmProfile('property-listings', {
  matchTerms: [
    'kos terdekat',
    'kost terdekat',
    'kontrakan terdekat',
    'apartemen terdekat',
  ],
  keywords:
    'kos terdekat kost terdekat kontrakan terdekat apartemen terdekat maps peta navigasi',
  maxRadiusKm: 150,
});

extendOsmProfile('tourism', {
  matchTerms: [
    'taman terdekat',
    'tempat wisata terdekat',
    'wisata alam terdekat',
    'pantai terdekat',
    'alun alun terdekat',
    'museum terdekat',
    'kebun binatang terdekat',
  ],
  filters: [
    { key: 'leisure', value: 'nature_reserve' },
    { key: 'natural', value: 'beach' },
  ],
  keywords:
    'taman tempat wisata wisata alam pantai alun alun museum kebun binatang maps peta navigasi street view',
  maxRadiusKm: 150,
});

extendOsmProfile('worship', {
  matchTerms: ['pura terdekat', 'vihara terdekat'],
  keywords: 'pura vihara kelenteng tempat ibadah maps peta navigasi',
  maxRadiusKm: 100,
});

extendOsmProfile('education', {
  matchTerms: [
    'sd terdekat',
    'smp terdekat',
    'sma terdekat',
    'pesantren terdekat',
    'lpk terdekat',
    'tempat les terdekat',
  ],
  filters: [
    { key: 'amenity', value: 'training' },
    { key: 'amenity', value: 'music_school' },
    { key: 'amenity', value: 'driving_school' },
  ],
  keywords:
    'sd smp sma pesantren lpk tempat les kursus sekolah pendidikan maps peta navigasi',
  maxRadiusKm: 100,
});

extendOsmProfile('fitness', {
  matchTerms: [
    'tempat fitness terdekat',
    'kolam renang terdekat',
    'lapangan futsal terdekat',
  ],
  filters: [
    { key: 'leisure', value: 'swimming_pool' },
    { key: 'leisure', value: 'pitch' },
    { key: 'sport', value: 'soccer' },
    { key: 'sport', value: 'futsal' },
  ],
  keywords:
    'tempat fitness kolam renang swimming pool lapangan futsal olahraga sport maps peta navigasi',
  maxRadiusKm: 100,
});

const EXTRA_OSM_SEARCH_PROFILES = [
  {
    id: 'building-services',
    matchTerms: [
          'material bangunan',
      'tukang bangunan',
      'renovasi rumah',
      'jasa kebersihan',
      'servis ac',
      'service ac',
      'ac',
      'kontraktor',
      'cat',
      'semen',
      'keramik',
      'hardware',
    ],
    filters: [
      { key: 'shop', value: 'hardware' },
      { key: 'shop', value: 'doityourself' },
      { key: 'shop', value: 'building_materials' },
      { key: 'shop', value: 'paint' },
      { key: 'craft', value: 'builder' },
      { key: 'craft', value: 'carpenter' },
      { key: 'craft', value: 'electrician' },
      { key: 'craft', value: 'plumber' },
      { key: 'craft', value: 'painter' },
      { key: 'craft', value: 'hvac' },
      { key: 'craft', value: 'cleaning' },
    ],
    defaultName: 'Toko Bahan Bangunan / Jasa Rumah',
    shortDescription:
      'Toko material, bahan bangunan, tukang, renovasi, kebersihan, atau servis AC dari OpenStreetMap.',
    fullDescription:
      'Data bahan bangunan dan jasa rumah diambil dari OpenStreetMap dan diurutkan berdasarkan jarak dari lokasi Anda.',
    keywords:
      'toko bahan bangunan material bangunan hardware tukang bangunan renovasi rumah jasa kebersihan servis ac service ac kontraktor cat semen keramik',
    category: {
      id: 'openstreetmap-building-services',
      name: 'Bangunan / Jasa Rumah',
      slug: 'bangunan-jasa-rumah',
      color_hex: '#B45309',
      description: 'Toko material dan jasa rumah dari OpenStreetMap.',
    },
    offering: {
      name: 'Material / jasa rumah',
      description: 'Ketersediaan material dan layanan mengikuti data lokasi.',
      offering_type: 'service',
    },
    maxRadiusKm: 100,
    priority: 4,
  },
  {
    id: 'computer-internet',
    matchTerms: [
      'smartphone',
      'hp',
      'handphone',
      'elektronik',
      'laptop',
      'komputer',
      'cctv',
      'service laptop',
      'servis laptop',
      'service printer',
      'servis printer',
      'printer',
      'internet',
      'wifi',
      'pembuatan website',
      'pembuatan aplikasi',
      'digital marketing',
      'seo',
      'desain grafis',
    ],
    filters: [
      { key: 'shop', value: 'computer' },
      { key: 'shop', value: 'electronics' },
      { key: 'shop', value: 'mobile_phone' },
      { key: 'shop', value: 'telecommunication' },
      { key: 'shop', value: 'camera' },
      { key: 'craft', value: 'electronics_repair' },
      { key: 'office', value: 'it' },
      { key: 'office', value: 'telecommunication' },
      { key: 'office', value: 'advertising_agency' },
    ],
    defaultName: 'Komputer / Internet / Digital',
    shortDescription:
      'Service laptop/printer, toko komputer, CCTV, internet, website, aplikasi, SEO, atau digital marketing dari OpenStreetMap.',
    fullDescription:
      'Data komputer/internet/digital diambil dari OpenStreetMap dan diurutkan berdasarkan jarak dari lokasi Anda.',
    keywords:
      'smartphone hp handphone elektronik laptop komputer cctv service laptop servis laptop service printer internet wifi website aplikasi digital marketing seo desain grafis',
    category: {
      id: 'openstreetmap-computer-internet',
      name: 'Komputer / Internet',
      slug: 'komputer-internet',
      color_hex: '#4F46E5',
      description: 'Komputer, internet, CCTV, dan jasa digital dari OpenStreetMap.',
    },
    offering: {
      name: 'Produk IT / jasa digital',
      description: 'Produk dan layanan digital mengikuti data lokasi.',
      offering_type: 'service',
    },
    maxRadiusKm: 100,
    priority: 5,
  },
  {
    id: 'agriculture-pet',
    matchTerms: [
      'pupuk',
      'bibit',
      'bibit tanaman',
      'pestisida',
      'alat pertanian',
      'pertanian',
      'peternakan',
      'pakan ternak',
      'pet shop',
      'klinik hewan',
      'grooming hewan',
      'hewan peliharaan',
    ],
    filters: [
      { key: 'shop', value: 'garden_centre' },
      { key: 'shop', value: 'agrarian' },
      { key: 'shop', value: 'farm' },
      { key: 'shop', value: 'pet' },
      { key: 'shop', value: 'animal_feed' },
      { key: 'amenity', value: 'veterinary' },
      { key: 'healthcare', value: 'veterinary' },
      { key: 'craft', value: 'gardener' },
    ],
    defaultName: 'Pertanian / Pet Shop',
    shortDescription:
      'Toko pupuk, bibit, alat pertanian, pakan ternak, pet shop, klinik hewan, atau grooming dari OpenStreetMap.',
    fullDescription:
      'Data pertanian dan hewan peliharaan diambil dari OpenStreetMap dan diurutkan berdasarkan jarak dari lokasi Anda.',
    keywords:
      'pupuk bibit tanaman pestisida alat pertanian peternakan pakan ternak pet shop klinik hewan grooming hewan',
    category: {
      id: 'openstreetmap-agriculture-pet',
      name: 'Pertanian / Hewan',
      slug: 'pertanian-hewan',
      color_hex: '#65A30D',
      description: 'Pertanian, peternakan, dan hewan dari OpenStreetMap.',
    },
    offering: {
      name: 'Produk pertanian / hewan',
      description: 'Produk dan layanan mengikuti informasi lokasi.',
      offering_type: 'product',
    },
    maxRadiusKm: 150,
    priority: 5,
  },
  {
    id: 'professional-services',
    matchTerms: [
      'notaris',
      'pengacara',
      'guru privat',
      'les privat',
      'fotografer',
      'videografer',
      'wedding organizer',
      'event organizer',
      'jasa pajak',
      'konsultan bisnis',
      'konsultan hukum',
      'konsultan keuangan',
      'lowongan kerja',
      'freelance',
      'magang',
      'seminar',
      'festival',
      'bazar',
    ],
    filters: [
      { key: 'office', value: 'lawyer' },
      { key: 'office', value: 'notary' },
      { key: 'office', value: 'accountant' },
      { key: 'office', value: 'tax_advisor' },
      { key: 'office', value: 'consulting' },
      { key: 'office', value: 'employment_agency' },
      { key: 'office', value: 'educational_institution' },
      { key: 'craft', value: 'photographer' },
      { key: 'amenity', value: 'events_venue' },
    ],
    defaultName: 'Jasa Profesional / Event',
    shortDescription:
      'Notaris, pengacara, konsultan, guru privat, fotografer, wedding organizer, lowongan, atau event dari OpenStreetMap.',
    fullDescription:
      'Data jasa profesional dan event diambil dari OpenStreetMap dan diurutkan berdasarkan jarak dari lokasi Anda.',
    keywords:
      'notaris pengacara guru privat les privat digital marketing seo website desain grafis fotografer videografer wedding organizer event organizer jasa pajak konsultan lowongan kerja freelance magang seminar festival bazar',
    category: {
      id: 'openstreetmap-professional-services',
      name: 'Jasa Profesional / Event',
      slug: 'jasa-profesional-event',
      color_hex: '#7C3AED',
      description: 'Jasa profesional, lowongan, dan event dari OpenStreetMap.',
    },
    offering: {
      name: 'Jasa profesional / event',
      description: 'Layanan mengikuti informasi lokasi.',
      offering_type: 'service',
    },
    maxRadiusKm: 150,
    priority: 4,
  },
  {
    id: 'logistics-rental-travel',
    matchTerms: [
      'rental mobil',
      'rental motor',
      'agen travel',
      'travel',
      'kurir',
      'ekspedisi',
      'logistik',
      'pengiriman',
      'pengiriman barang',
      'antar',
      'kirim',
    ],
    filters: [
      { key: 'amenity', value: 'car_rental' },
      { key: 'amenity', value: 'bicycle_rental' },
      { key: 'shop', value: 'rental' },
      { key: 'shop', value: 'motorcycle' },
      { key: 'tourism', value: 'travel_agency' },
      { key: 'office', value: 'logistics' },
      { key: 'office', value: 'courier' },
      { key: 'amenity', value: 'post_office' },
    ],
    defaultName: 'Travel / Rental / Logistik',
    shortDescription:
      'Rental mobil/motor, agen travel, kurir, ekspedisi, logistik, atau pengiriman barang dari OpenStreetMap.',
    fullDescription:
      'Data travel/rental/logistik diambil dari OpenStreetMap dan diurutkan berdasarkan jarak dari lokasi Anda.',
    keywords:
      'rental mobil rental motor agen travel kurir ekspedisi logistik pengiriman barang antar kirim',
    category: {
      id: 'openstreetmap-logistics-rental-travel',
      name: 'Travel / Logistik',
      slug: 'travel-logistik',
      color_hex: '#0891B2',
      description: 'Travel, rental, dan logistik dari OpenStreetMap.',
    },
    offering: {
      name: 'Travel / rental / pengiriman',
      description: 'Layanan mengikuti informasi lokasi.',
      offering_type: 'service',
    },
    maxRadiusKm: 150,
    priority: 4,
  },
  {
    id: 'property-listings',
    matchTerms: [
      'properti',
      'rumah dijual',
      'tanah dijual',
      'ruko',
      'apartemen',
      'kost',
      'kos',
      'kontrakan',
      'rumah kontrakan',
      'sewa rumah',
    ],
    filters: [
      { key: 'office', value: 'estate_agent' },
      { key: 'tourism', value: 'apartment' },
      { key: 'tourism', value: 'guest_house' },
    ],
    defaultName: 'Properti / Kost',
    shortDescription:
      'Agen properti, rumah dijual, tanah dijual, ruko, apartemen, kost, atau kontrakan dari OpenStreetMap.',
    fullDescription:
      'Data properti diambil dari OpenStreetMap dan diurutkan berdasarkan jarak dari lokasi Anda.',
    keywords:
      'properti rumah dijual tanah dijual ruko apartemen kost kos kontrakan sewa rumah estate agent',
    category: {
      id: 'openstreetmap-property-listings',
      name: 'Properti / Kost',
      slug: 'properti-kost',
      color_hex: '#0F766E',
      description: 'Properti dan kost dari OpenStreetMap.',
    },
    offering: {
      name: 'Listing properti',
      description: 'Ketersediaan properti mengikuti informasi lokasi.',
      offering_type: 'service',
    },
    maxRadiusKm: 150,
    priority: 4,
  },
];

OSM_SEARCH_PROFILES.push(...EXTRA_OSM_SEARCH_PROFILES);


OSM_SEARCH_PROFILES.push(
  {
    id: 'transportation',
    matchTerms: [
      'stasiun',
      'stasiun terdekat',
      'stasiun kereta terdekat',
      'halte terdekat',
      'halte busway terdekat',
      'terminal terdekat',
      'terminal bus',
      'band',
      'bandara',
      'airport',
      'transportasi',
    ],
    filters: [
      { key: 'railway', value: 'station' },
      { key: 'railway', value: 'halt' },
      { key: 'railway', value: 'tram_stop' },
      { key: 'amenity', value: 'bus_station' },
      { key: 'highway', value: 'bus_stop' },
      { key: 'public_transport', value: 'station' },
      { key: 'public_transport', value: 'platform' },
      { key: 'amenity', value: 'ferry_terminal' },
      { key: 'aeroway', value: 'aerodrome' },
      { key: 'aeroway', value: 'terminal' },
    ],
    defaultName: 'Transportasi Publik',
    shortDescription:
      'Stasiun, halte, terminal, bandara, atau transportasi publik dari OpenStreetMap.',
    fullDescription:
      'Data transportasi publik diambil dari OpenStreetMap dan diurutkan berdasarkan jarak dari lokasi pencarian.',
    keywords:
      'stasiun stasiun kereta halte halte busway terminal terminal bus bandara airport transportasi publik maps peta google maps waze navigasi rute',
    category: {
      id: 'openstreetmap-transportation',
      name: 'Transportasi Publik',
      slug: 'transportasi-publik',
      color_hex: '#2563EB',
      description: 'Stasiun, halte, terminal, dan bandara dari OpenStreetMap.',
    },
    offering: {
      name: 'Akses transportasi',
      description: 'Informasi transportasi mengikuti data lokasi.',
      offering_type: 'service',
    },
    maxRadiusKm: 150,
    priority: 6,
  },
  {
    id: 'beauty-wellness',
    matchTerms: [
      'salon',
      'salon terdekat',
      'barbershop',
      'barbershop terdekat',
      'pangkas rambut',
      'pangkas rambut terdekat',
      'spa',
      'spa terdekat',
      'pijat',
      'pijat terdekat',
      'massage',
    ],
    filters: [
      { key: 'shop', value: 'hairdresser' },
      { key: 'shop', value: 'beauty' },
      { key: 'shop', value: 'massage' },
      { key: 'leisure', value: 'spa' },
      { key: 'leisure', value: 'sauna' },
    ],
    defaultName: 'Salon / Barbershop / Spa',
    shortDescription:
      'Salon, barbershop, pangkas rambut, spa, atau pijat dari OpenStreetMap.',
    fullDescription:
      'Data kecantikan dan wellness diambil dari OpenStreetMap dan diurutkan berdasarkan jarak dari lokasi pencarian.',
    keywords:
      'salon barbershop pangkas rambut spa pijat massage beauty wellness maps peta navigasi',
    category: {
      id: 'openstreetmap-beauty-wellness',
      name: 'Salon / Wellness',
      slug: 'salon-wellness',
      color_hex: '#DB2777',
      description: 'Salon, barbershop, spa, dan pijat dari OpenStreetMap.',
    },
    offering: {
      name: 'Layanan kecantikan / wellness',
      description: 'Layanan mengikuti informasi lokasi.',
      offering_type: 'service',
    },
    maxRadiusKm: 100,
    priority: 5,
  },
  {
    id: 'weather-local-info',
    matchTerms: [
      'cuaca',
      'cuaca hari ini',
      'prakiraan cuaca',
      'ramalan cuaca',
      'cuaca esok hari',
      'bmkg',
    ],
    filters: [
      { key: 'office', value: 'government' },
      { key: 'amenity', value: 'townhall' },
      { key: 'government' },
      { key: 'office', value: 'administrative' },
    ],
    defaultName: 'Info Lokal / Cuaca',
    shortDescription:
      'Titik layanan publik dan informasi lokal terkait cuaca/lokasi dari OpenStreetMap.',
    fullDescription:
      'GeoSeek mengarahkan keyword cuaca ke data lokasi dan layanan publik terdekat. Data prakiraan real-time dapat dihubungkan ke API cuaca/BMKG pada tahap berikutnya.',
    keywords:
      'cuaca cuaca hari ini prakiraan cuaca ramalan cuaca cuaca esok hari bmkg informasi lokal kantor pemerintah maps peta lokasi',
    category: {
      id: 'openstreetmap-weather-local-info',
      name: 'Info Lokal / Cuaca',
      slug: 'info-lokal-cuaca',
      color_hex: '#0EA5E9',
      description: 'Info lokal dan titik layanan publik dari OpenStreetMap.',
    },
    offering: {
      name: 'Informasi lokal / cuaca',
      description: 'Informasi mengikuti data lokasi publik.',
      offering_type: 'service',
    },
    maxRadiusKm: 150,
    priority: 7,
  },
);

const MAP_NAVIGATION_KEYWORDS =
  'maps google maps maps google peta google earth waze street view google maps satellite peta indonesia navigasi rute lokasi local seo terdekat';

OSM_SEARCH_PROFILES.forEach((profile) => {
  profile.keywords = [profile.keywords, MAP_NAVIGATION_KEYWORDS]
    .filter(Boolean)
    .join(' ');
});

const OSM_TAG_VALUE_KEYWORDS = {
  atm: 'atm bank tunai uang cash',
  bakery: 'roti bakery kue makanan kuliner',
  bank: 'bank atm uang tunai finansial',
  cafe: 'cafe kafe kopi coffee kedai minuman',
  car: 'mobil kendaraan otomotif auto',
  car_parts: 'suku cadang sparepart mobil otomotif',
  car_repair: 'bengkel service servis mobil kendaraan otomotif',
  car_wash: 'cuci mobil kendaraan otomotif',
  chemist: 'apotek farmasi obat kesehatan',
  clothes: 'baju pakaian fashion toko belanja',
  clinic: 'klinik kesehatan dokter medis',
  coffee: 'kopi coffee cafe kafe kedai',
  college: 'kampus kuliah pendidikan college',
  computer: 'komputer laptop toko elektronik',
  convenience: 'minimarket toko belanja kebutuhan harian',
  dentist: 'dokter gigi klinik kesehatan',
  department_store: 'mall toko belanja department store',
  doctors: 'dokter klinik kesehatan medis',
  electronics: 'elektronik hp handphone gadget toko',
  fast_food: 'fast food makanan kuliner restoran',
  fitness_centre: 'gym fitness fitnes olahraga',
  food_court: 'food court kuliner makanan restoran',
  fuel: 'spbu bbm bensin pom bahan bakar',
  government:
    'pemerintah pemerintahan kantor instansi layanan publik pemda administrasi publik',
  townhall:
    'kantor pemerintah balai kota kecamatan kelurahan desa pemda layanan publik',
  courthouse: 'pengadilan kejaksaan hukum layanan publik kantor pemerintah',
  police: 'polisi polsek polres polda layanan publik kantor pemerintah',
  post_office: 'kantor pos pos indonesia bumn kirim paket surat logistik',
  tax: 'pajak kpp kantor pajak layanan publik npwp samsat',
  administrative:
    'administrasi kantor pemerintah layanan publik administrasi publik',
  utility: 'pln pdam utilitas listrik air bumn bumd layanan pelanggan',
  telecommunication:
    'telkom telekomunikasi bumn layanan pelanggan internet telepon',
  furniture: 'furniture furnitur mebel perabot toko',
  guest_house: 'penginapan guesthouse guest house hotel',
  hairdresser: 'salon potong rambut kecantikan',
  hardware: 'toko bangunan hardware alat',
  hospital: 'rumah sakit rs hospital kesehatan medis',
  hostel: 'hostel penginapan hotel travel',
  hotel: 'hotel penginapan travel wisata',
  kindergarten: 'tk taman kanak kanak sekolah pendidikan',
  laundry: 'laundry binatu cuci pakaian',
  marketplace: 'pasar market toko belanja',
  mobile_phone: 'hp handphone ponsel toko elektronik',
  motel: 'motel hotel penginapan travel',
  museum: 'museum wisata rekreasi edukasi',
  park: 'taman wisata rekreasi',
  pharmacy: 'apotek farmasi obat kesehatan',
  place_of_worship: 'tempat ibadah masjid mushola gereja pura vihara worship',
  restaurant: 'restoran restaurant makan makanan kuliner warung nasi',
  school: 'sekolah pendidikan belajar',
  shoes: 'sepatu toko fashion belanja',
  sports: 'olahraga sport alat olahraga toko',
  sports_centre: 'pusat olahraga sport gym fitness',
  supermarket: 'supermarket minimarket toko belanja groceries',
  tyres: 'ban tambal ban bengkel otomotif mobil',
  university: 'universitas kampus kuliah pendidikan',
  variety_store: 'toko serba ada belanja produk',
  zoo: 'kebun binatang zoo wisata rekreasi',
};


Object.assign(OSM_TAG_VALUE_KEYWORDS, {
  accountant: 'akuntan konsultan keuangan jasa pajak kantor jasa profesional',
  advertising_agency:
    'digital marketing seo desain grafis website aplikasi jasa profesional',
  agrarian: 'pupuk bibit pestisida alat pertanian toko pertanian',
  animal_feed: 'pakan ternak peternakan hewan toko',
  apartment: 'apartemen properti penginapan sewa',
  apartments: 'apartemen properti sewa rumah kost',
  baby_goods: 'perlengkapan bayi mainan anak toko produk bayi',
  bag: 'tas fashion aksesoris toko belanja',
  beverages: 'minuman beverage cafe warung kuliner',
  building_materials: 'bahan bangunan material semen cat keramik hardware toko',
  builder: 'tukang bangunan renovasi rumah kontraktor jasa',
  camera: 'kamera cctv fotografer videografer elektronik',
  car_rental: 'rental mobil sewa mobil travel kendaraan',
  carpenter: 'tukang kayu renovasi rumah furniture jasa bangunan',
  cleaning: 'jasa kebersihan cleaning service rumah kantor',
  commercial: 'ruko properti toko kantor sewa jual',
  confectionery: 'makanan kue minuman kuliner toko',
  consulting: 'konsultan bisnis konsultan hukum konsultan keuangan jasa',
  copyshop: 'percetakan fotocopy kebutuhan kantor printing',
  courier: 'kurir ekspedisi logistik pengiriman barang paket',
  doityourself: 'toko bahan bangunan hardware material alat rumah',
  electrician: 'tukang listrik renovasi rumah jasa bangunan',
  electronics_repair: 'service laptop service printer servis elektronik komputer',
  employment_agency: 'lowongan kerja freelance magang employment',
  events_venue: 'event seminar festival bazar wedding organizer venue',
  farm: 'pertanian pupuk bibit pakan ternak toko',
  financial: 'keuangan koperasi pegadaian bank pembayaran qris',
  garden_centre: 'pupuk bibit tanaman pestisida alat pertanian toko',
  gift: 'aksesoris hadiah mainan toko belanja',
  greengrocer: 'makanan sayur buah toko kelontong warung',
  herbalist: 'herbal jamu obat alami kesehatan apotek',
  house: 'rumah properti rumah dijual kontrakan sewa',
  houseware: 'peralatan rumah tangga alat dapur furniture toko',
  hvac: 'servis ac service ac pendingin ruangan jasa rumah',
  it: 'komputer laptop website aplikasi internet wifi digital',
  jewelry: 'perhiasan emas aksesoris toko fashion',
  kitchen: 'peralatan dapur kitchen alat rumah tangga toko',
  laboratory: 'laboratorium lab kesehatan medis',
  lawyer: 'pengacara konsultan hukum kantor hukum jasa profesional',
  logistics: 'logistik ekspedisi kurir pengiriman barang paket',
  medical_lab: 'laboratorium lab kesehatan medis',
  medical_supply: 'alat kesehatan alkes kesehatan apotek',
  notary: 'notaris hukum legal akta jasa profesional',
  painter: 'tukang cat renovasi rumah jasa bangunan',
  pawnbroker: 'pegadaian gadai keuangan koperasi',
  pet: 'pet shop hewan peliharaan pakan hewan grooming',
  plumber: 'tukang pipa renovasi rumah jasa bangunan',
  rental: 'rental sewa mobil motor travel',
  tailor: 'konveksi jahit fashion pakaian baju',
  tax_advisor: 'jasa pajak konsultan pajak akuntan keuangan',
  telecommunication: 'internet wifi telkom telekomunikasi komputer',
  toys: 'mainan anak mainan bayi toko produk',
  travel_agency: 'agen travel wisata rental mobil perjalanan',
  veterinary: 'klinik hewan dokter hewan pet shop grooming hewan',
  watches: 'jam tangan aksesoris fashion toko',

  aerodrome: 'bandara airport transportasi publik travel',
  beauty: 'salon kecantikan beauty skincare kosmetik',
  bus_station: 'terminal bus halte transportasi publik',
  bus_stop: 'halte bus halte busway transportasi publik',
  ferry_terminal: 'terminal ferry pelabuhan transportasi publik',
  massage: 'pijat massage spa wellness',
  midwife: 'bidan klinik kesehatan ibu anak',
  nature_reserve: 'wisata alam taman rekreasi alam',
  platform: 'halte platform transportasi publik',
  public_transport: 'transportasi publik stasiun halte terminal',
  sauna: 'spa sauna wellness pijat',
  soccer: 'lapangan futsal sepak bola olahraga',
  spa: 'spa pijat massage wellness',
  station: 'stasiun transportasi publik kereta',
  swimming_pool: 'kolam renang olahraga rekreasi',
  terminal: 'terminal transportasi publik bandara bus',
  training: 'lpk kursus tempat les pelatihan pendidikan',
});

const normalizeOsmTagValue = (value) =>
  normalizeText(String(value || '').replace(/_/g, ' '));

const getOsmFilterConditions = (filter) => filter.conditions || [filter];

const buildOsmFilterCondition = (filter) =>
  getOsmFilterConditions(filter)
    .map((condition) => {
      if (condition.value === undefined || condition.value === null) {
        return `["${condition.key}"]`;
      }

      return `["${condition.key}"="${condition.value}"]`;
    })
    .join('');

const osmTagMatchesCondition = (tags, condition) => {
  const tagValue = tags[condition.key];

  if (tagValue === undefined || tagValue === null || tagValue === '') {
    return false;
  }

  if (condition.value === undefined || condition.value === null) {
    return true;
  }

  return (
    normalizeOsmTagValue(tagValue) === normalizeOsmTagValue(condition.value)
  );
};

const osmTagMatchesFilter = (tags, filter) =>
  getOsmFilterConditions(filter).every((condition) =>
    osmTagMatchesCondition(tags, condition),
  );

const buildExpandedOsmTokens = (tokens) => {
  const expanded = new Set(tokens);

  tokens.forEach((token) => {
    (SYNONYMS[token] || []).forEach((synonym) => {
      normalizeText(synonym)
        .split(/\s+/)
        .filter(Boolean)
        .forEach((synonymToken) => expanded.add(synonymToken));
    });
  });

  return expanded;
};

const getOsmProfilesByIds = (ids) => {
  const idSet = new Set(ids);

  return OSM_SEARCH_PROFILES.filter((profile) => idSet.has(profile.id)).slice(
    0,
    OSM_PROFILE_MATCH_LIMIT,
  );
};

const scoreOsmProfileMatch = (
  profile,
  normalizedText,
  directTokens,
  expandedTokens,
) => {
  const matchScore = profile.matchTerms.reduce((total, term) => {
    const normalizedTerm = normalizeText(term);

    if (!normalizedTerm) {
      return total;
    }

    const termWordCount = normalizedTerm.split(/\s+/).filter(Boolean).length;

    if (normalizedTerm.includes(' ') && normalizedText.includes(normalizedTerm)) {
      return total + 12 + termWordCount;
    }

    if (
      directTokens.has(normalizedTerm) ||
      includesToken(normalizedText, normalizedTerm)
    ) {
      return total + 6;
    }

    if (expandedTokens.has(normalizedTerm)) {
      return total + 2;
    }

    return total;
  }, 0);

  return matchScore > 0 ? matchScore + Number(profile.priority || 0) : 0;
};

const getOsmSearchProfiles = (query, category) => {
  const searchText = [query, category].filter(Boolean).join(' ');
  const normalized = normalizeText(searchText);

  if (!normalized) {
    return [];
  }

  const directTokens = new Set(tokenizeQuery(searchText));
  const expandedTokens = buildExpandedOsmTokens(directTokens);

  if (isWeatherSearchQuery(searchText)) {
    return getOsmProfilesByIds([
      'weather-local-info',
      'government-office',
      'bumn-office',
      'healthcare',
      'tourism',
      'transportation',
    ]);
  }

  if (isGenericMapSearchQuery(searchText)) {
    return getOsmProfilesByIds([
      'restaurant',
      'cafe',
      'fuel',
      'bank-atm',
      'retail',
      'pharmacy',
      'healthcare',
      'hotel',
      'tourism',
      'transportation',
    ]);
  }

  const matchedProfiles = OSM_SEARCH_PROFILES.map((profile) => ({
    profile,
    score: scoreOsmProfileMatch(
      profile,
      normalized,
      directTokens,
      expandedTokens,
    ),
  }))
    .filter((item) => item.score > 0)
    .sort((a, b) => b.score - a.score);

  if (matchedProfiles.length) {
    return matchedProfiles
      .slice(0, OSM_PROFILE_MATCH_LIMIT)
      .map((item) => item.profile);
  }

  if (isFuelSearchQuery(searchText)) {
    return OSM_SEARCH_PROFILES.filter((profile) => profile.id === 'fuel');
  }

  return [];
};

const getOsmElementProfile = (element, profiles) => {
  const tags = element.tags || {};

  return (
    profiles.find((profile) =>
      profile.filters.some((filter) => osmTagMatchesFilter(tags, filter)),
    ) || null
  );
};

const buildOsmTagKeywordText = (tags = {}) => {
  const relevantValues = [
    tags.amenity,
    tags.shop,
    tags.tourism,
    tags.leisure,
    tags.healthcare,
    tags.office,
    tags.religion,
    tags.cuisine,
    tags.brand,
    tags.operator,
  ];

  return relevantValues
    .flatMap((value) => {
      const normalizedValue = normalizeOsmTagValue(value);

      if (!normalizedValue) {
        return [];
      }

      return [
        normalizedValue,
        OSM_TAG_VALUE_KEYWORDS[value] ||
          OSM_TAG_VALUE_KEYWORDS[normalizedValue] ||
          '',
      ];
    })
    .filter(Boolean)
    .join(' ');
};

const buildOsmOffering = (profile) => ({
  id: `osm-${profile.id}-offering`,
  name: profile.offering.name,
  description: profile.offering.description,
  offering_type: profile.offering.offering_type,
  price: null,
  stock_status: 'by_request',
  stock_label: STOCK_STATUS_LABELS.by_request,
  stock_quantity: null,
  is_verified: false,
});

const buildOsmOfferings = (profile) => [buildOsmOffering(profile)];

const buildOsmLiveStatus = (tags = {}) => {
  const openingHours = String(tags.opening_hours || '').trim();

  if (openingHours.toLowerCase().includes('24/7')) {
    return {
      status: 'open',
      label: 'Buka 24 jam',
      crowd: 'Cek lokasi',
      updated_label: 'Sumber OSM',
      source: 'openstreetmap',
    };
  }

  return {
    status: 'open',
    label: openingHours ? `Jam: ${openingHours}` : 'Cek jam operasional',
    crowd: 'Cek lokasi',
    updated_label: 'Sumber OSM',
    source: 'openstreetmap',
  };
};

const getOsmCoordinate = (element) => {
  const lat = toNumber(element.lat ?? element.center?.lat);
  const lon = toNumber(element.lon ?? element.center?.lon);

  if (lat === null || lon === null) {
    return null;
  }

  return { lat, lng: lon };
};

const buildOsmAddress = (tags = {}) =>
  [
    tags['addr:housenumber'],
    tags['addr:street'],
    tags['addr:suburb'] || tags['addr:village'],
    tags['addr:city'] || tags['addr:district'] || tags['addr:county'],
    tags['addr:province'] || tags['addr:state'],
  ]
    .filter(Boolean)
    .join(', ');

const buildOsmMapsUrl = (latitude, longitude) =>
  `https://www.google.com/maps/dir/?api=1&destination=${latitude},${longitude}`;


const FALLBACK_DATASET_FILES = [
  {
    filePath: path.join(
      __dirname,
      '../../../data/geoseek/geo_places_sample.csv',
    ),
    maxRows: 200,
  },
  {
    filePath: path.join(
      __dirname,
      '../../../data/geoseek/generated/geo_places_1M.csv',
    ),
    maxRows: 2500,
  },
];

let fallbackDatasetPromise = null;

const parseGeoSeekCsvLine = (line) => {
  const columns = String(line || '').split(',');

  if (columns.length < 16 || columns[0] === 'id') {
    return null;
  }

  const tail = columns.slice(-8);
  const middle = columns.slice(4, -8);

  if (middle.length < 4) {
    return null;
  }

  return {
    id: columns[0],
    name: columns[1],
    category: columns[2],
    subcategory: columns[3],
    address: middle.slice(0, -3).join(', '),
    district: middle[middle.length - 3],
    city: middle[middle.length - 2],
    province: middle[middle.length - 1],
    latitude: toNumber(tail[0]),
    longitude: toNumber(tail[1]),
    phone_number: tail[2],
    opening_hours: tail[3],
    rating_average: toNumber(tail[4]),
    rating_count: Number(tail[5]) || 0,
    source: tail[6],
    verification_status: tail[7],
  };
};

const readGeoSeekCsvFile = async (filePath, maxRows) => {
  if (!fs.existsSync(filePath)) {
    return [];
  }

  const rows = [];
  const stream = fs.createReadStream(filePath, { encoding: 'utf8' });
  const reader = readline.createInterface({
    input: stream,
    crlfDelay: Infinity,
  });

  try {
    for await (const line of reader) {
      const row = parseGeoSeekCsvLine(line);

      if (!row) {
        continue;
      }

      rows.push(row);

      if (rows.length >= maxRows) {
        reader.close();
        stream.destroy();
        break;
      }
    }
  } catch (err) {
    console.error('Gagal membaca dataset fallback GeoSeek CSV', {
      filePath,
      message: err.message,
    });
    throw err;
  }

  return rows;
};

const loadFallbackDatasetRows = () => {
  if (!fallbackDatasetPromise) {
    fallbackDatasetPromise = Promise.all(
      FALLBACK_DATASET_FILES.map((item) =>
        readGeoSeekCsvFile(item.filePath, item.maxRows),
      ),
    ).then((groups) => groups.flat());
  }

  return fallbackDatasetPromise;
};

const slugify = (value) => normalizeText(value).replace(/\s+/g, '-');

const FALLBACK_CATEGORY_KEYWORDS = {
  apotek:
    'apotek apotek 24 jam toko obat farmasi obat herbal kesehatan maps peta navigasi',
  atm:
    'atm atm bca atm bri atm mandiri atm bni bank tunai uang cash maps peta navigasi',
  bank:
    'bank atm atm bca atm bri atm mandiri atm bni tunai uang cash maps peta navigasi',
  barbershop:
    'salon barbershop pangkas rambut potong rambut kecantikan maps peta navigasi',
  bengkel:
    'bengkel bengkel mobil bengkel motor tambal ban otomotif service servis maps peta navigasi',
  cafe:
    'cafe kafe cafe aesthetic kedai kopi warkop tempat nongkrong kopi minuman boba es kopi susu maps peta navigasi',
  gereja: 'gereja tempat ibadah kristen katolik maps peta navigasi',
  gym:
    'gym tempat fitness kolam renang lapangan futsal olahraga rekreasi maps peta navigasi',
  hotel:
    'hotel penginapan villa homestay resort travel wisata maps peta navigasi',
  kampus:
    'kampus universitas pendidikan sekolah tempat les lpk maps peta navigasi',
  kost:
    'kost kos kontrakan apartemen properti sewa rumah penginapan maps peta navigasi',
  laundry: 'laundry jasa cuci pakaian maps peta navigasi',
  logistik:
    'stasiun stasiun kereta halte halte busway terminal terminal bus bandara airport transportasi publik maps peta navigasi',
  mall:
    'mall pusat perbelanjaan supermarket minimarket toko pasar belanja maps peta navigasi',
  masjid: 'masjid mushola musala tempat ibadah islam maps peta navigasi',
  pasar:
    'pasar toko kelontong jajanan pasar modern kuliner belanja maps peta navigasi',
  restoran:
    'restoran rumah makan tempat makan kuliner kuliner malam makanan enak makanan pedas makanan viral murah cemilan jajanan katering catering maps peta navigasi',
  'rumah sakit':
    'rumah sakit puskesmas klinik dokter dokter gigi bidan kesehatan maps peta navigasi',
  sekolah:
    'sekolah sd smp sma pesantren lpk tempat les pendidikan maps peta navigasi',
  spbu:
    'spbu pom bensin pertamini pertamina bensin bbm fuel gas station maps peta navigasi',
  toko:
    'toko minimarket indomaret alfamart supermarket toko kelontong toko baju toko buku toko obat belanja maps peta navigasi',
  'toko bangunan':
    'toko bahan bangunan material bangunan hardware renovasi rumah maps peta navigasi',
  'toko elektronik':
    'toko elektronik smartphone hp laptop komputer gadget internet wifi maps peta navigasi',
  wisata:
    'wisata tempat wisata wisata alam pantai alun alun museum kebun binatang taman street view maps peta navigasi',
};

const FALLBACK_UNIVERSAL_KEYWORDS = [
  MAP_NAVIGATION_KEYWORDS,
  'cuaca cuaca hari ini prakiraan cuaca bmkg ramalan cuaca cuaca esok hari informasi lokal lokasi',
].join(' ');

const getFallbackKeywordText = (row) => {
  const normalizedCategory = normalizeText(row.category);
  const normalizedSubcategory = normalizeText(row.subcategory);
  const matchedKeywordValues = Object.entries(FALLBACK_CATEGORY_KEYWORDS)
    .filter(
      ([key]) =>
        normalizedCategory.includes(key) || normalizedSubcategory.includes(key),
    )
    .map(([, value]) => value);

  return [
    row.name,
    row.category,
    row.subcategory,
    row.address,
    row.district,
    row.city,
    row.province,
    FALLBACK_UNIVERSAL_KEYWORDS,
    ...matchedKeywordValues,
  ]
    .filter(Boolean)
    .join(' ');
};

const buildFallbackLiveStatus = (openingHours) => {
  const normalized = normalizeText(openingHours);

  if (normalized.includes('24')) {
    return {
      status: 'open',
      label: 'Buka 24 jam',
      crowd: 'Aktif',
      updated_label: 'Dataset GeoSeek',
      source: 'GeoSeek CSV',
    };
  }

  return {
    status: 'open',
    label: openingHours || 'Jam operasional tersedia',
    crowd: 'Normal',
    updated_label: 'Dataset GeoSeek',
    source: 'GeoSeek CSV',
  };
};

const toFallbackDatasetPlace = (row, origin) => {
  const latitude = toNumber(row.latitude);
  const longitude = toNumber(row.longitude);

  if (latitude === null || longitude === null || !origin) {
    return null;
  }

  const distanceKm = calculateDistanceKm(origin.lat, origin.lng, latitude, longitude);
  const slug = slugify(row.category || 'tempat-lokal') || 'tempat-lokal';
  const keywordText = getFallbackKeywordText(row);
  const offerings = [
    {
      id: `geoseek-csv-${row.id}-offering`,
      name: row.subcategory || row.category || 'Info tempat',
      description: `Kategori ${row.category || 'tempat lokal'} dari dataset GeoSeek.`,
      offering_type: 'service',
      price: null,
      stock_status: 'by_request',
      stock_label: STOCK_STATUS_LABELS.by_request,
      stock_quantity: null,
      is_verified: row.verification_status === 'verified',
    },
  ];

  return {
    id: `geoseek-csv-${row.id}`,
    name: row.name,
    short_description: `${row.category || 'Tempat lokal'} · ${row.subcategory || row.city || 'Dataset GeoSeek'}`,
    full_description: [
      `Data fallback dari dataset GeoSeek CSV untuk ${row.category || 'tempat lokal'}.`,
      `Kata kunci lokasi: ${keywordText}.`,
    ].join(' '),
    address: [row.address, row.district, row.city, row.province]
      .filter(Boolean)
      .join(', '),
    city: row.city,
    province: row.province,
    latitude,
    longitude,
    phone_number: row.phone_number,
    whatsapp_number: '',
    google_maps_url: buildOsmMapsUrl(latitude, longitude),
    website_url: '',
    price_level: 'unknown',
    average_price: null,
    rating_average: row.rating_average,
    rating_count: row.rating_count,
    status: 'published',
    is_verified: row.verification_status === 'verified',
    category: {
      id: `geoseek-csv-${slug}`,
      name: row.category || 'Tempat Lokal',
      slug,
      color_hex: '#087F6D',
      description: `${row.category || 'Tempat lokal'} dari dataset GeoSeek CSV.`,
    },
    distance_km: Number(distanceKm.toFixed(2)),
    offerings,
    offerings_summary: summarizeOfferings(offerings),
    live_status: buildFallbackLiveStatus(row.opening_hours),
    external_source: 'geoseek_csv',
    external_search_match: true,
  };
};

const loadFallbackDatasetPlaces = async (origin) => {
  if (!origin) {
    return [];
  }

  const rows = await loadFallbackDatasetRows();

  return rows
    .map((row) => toFallbackDatasetPlace(row, origin))
    .filter(Boolean);
};

const toOsmPlace = (element, origin, profile) => {
  const coordinate = getOsmCoordinate(element);

  if (!coordinate || !origin || !profile) {
    return null;
  }

  const tags = element.tags || {};
  const latitude = coordinate.lat;
  const longitude = coordinate.lng;
  const distanceKm = calculateDistanceKm(
    origin.lat,
    origin.lng,
    latitude,
    longitude,
  );
  const brandOrOperator = tags.brand || tags.operator || '';
  const name = tags.name || brandOrOperator || profile.defaultName;
  const address = buildOsmAddress(tags);
  const phoneNumber = tags.phone || tags['contact:phone'] || '';
  const websiteUrl = tags.website || tags['contact:website'] || '';
  const osmKeywords = [profile.keywords, buildOsmTagKeywordText(tags)]
    .filter(Boolean)
    .join(' ');
  const offerings = buildOsmOfferings(profile);

  return {
    id: `osm-${profile.id}-${element.type}-${element.id}`,
    name,
    short_description: `${profile.shortDescription}${brandOrOperator && brandOrOperator !== name ? ` · ${brandOrOperator}` : ''}`,
    full_description: [
      tags.description,
      profile.fullDescription,
      tags.cuisine ? `Jenis kuliner: ${tags.cuisine}.` : '',
      osmKeywords ? `Kata kunci lokasi: ${osmKeywords}.` : '',
    ]
      .filter(Boolean)
      .join(' '),
    address: address || [latitude, longitude].join(', '),
    city:
      tags['addr:city'] || tags['addr:district'] || tags['addr:county'] || '',
    province: tags['addr:province'] || tags['addr:state'] || '',
    latitude,
    longitude,
    phone_number: phoneNumber,
    whatsapp_number: '',
    google_maps_url: buildOsmMapsUrl(latitude, longitude),
    website_url: websiteUrl,
    price_level: 'unknown',
    average_price: null,
    rating_average: null,
    rating_count: 0,
    status: 'published',
    is_verified: false,
    category: profile.category,
    distance_km: Number(distanceKm.toFixed(2)),
    offerings,
    offerings_summary: summarizeOfferings(offerings),
    live_status: buildOsmLiveStatus(tags),
    external_source: 'openstreetmap',
    external_search_match: true,
  };
};

const buildOsmOverpassQuery = (
  profiles,
  radiusMeters,
  origin,
  overpassLimit,
) => {
  const clauses = [];
  const seenClauses = new Set();

  profiles.forEach((profile) => {
    profile.filters.forEach((filter) => {
      const condition = buildOsmFilterCondition(filter);

      const clause = `nwr${condition}(around:${radiusMeters},${origin.lat},${origin.lng});`;

      if (!seenClauses.has(clause)) {
        seenClauses.add(clause);
        clauses.push(clause);
      }
    });
  });

  return `
    [out:json][timeout:${OSM_OVERPASS_QUERY_TIMEOUT_SECONDS}];
    (
      ${clauses.join('\n      ')}
    );
    out center tags ${overpassLimit};
  `;
};

const getOsmSearchRadiusKm = (profiles, radiusKm) => {
  const requestedRadiusKm = Math.max(Number(radiusKm || DEFAULT_RADIUS_KM), 1);
  const maxProfileRadiusKm = profiles.reduce(
    (maxRadiusKm, profile) => Math.max(maxRadiusKm, profile.maxRadiusKm || 25),
    25,
  );

  return Math.min(requestedRadiusKm, maxProfileRadiusKm);
};

const isRetriableOverpassError = (err) => {
  const status = err.response?.status;

  if (!status) {
    return true;
  }

  return [408, 429, 500, 502, 503, 504].includes(status);
};

const summarizeOverpassError = (err, endpoint) => ({
  endpoint,
  message: err.message,
  status: err.response?.status,
  data:
    typeof err.response?.data === 'string'
      ? err.response.data.slice(0, 500)
      : err.response?.data,
});

const getOverpassRemark = (data) =>
  typeof data?.remark === 'string' ? data.remark.trim() : '';

const isOverpassRemarkError = (data) => {
  const remark = getOverpassRemark(data);

  return /runtime error|timed out|timeout/i.test(remark);
};

const postOverpassQuery = async (
  body,
  endpointIndex = 0,
  previousErrors = [],
) => {
  const endpoint = OVERPASS_ENDPOINTS[endpointIndex];

  try {
    const response = await axios.post(endpoint, body.toString(), {
      timeout: OSM_HTTP_TIMEOUT_MS,
      headers: {
        Accept: 'application/json',
        'Content-Type': 'application/x-www-form-urlencoded; charset=utf-8',
        'User-Agent': 'GeoSeek/1.0',
      },
    });

    if (isOverpassRemarkError(response.data)) {
      const error = new Error(getOverpassRemark(response.data));
      error.response = {
        status: response.status,
        data: response.data,
      };
      throw error;
    }

    return response;
  } catch (err) {
    const overpassErrors = [
      ...previousErrors,
      summarizeOverpassError(err, endpoint),
    ];

    if (
      endpointIndex < OVERPASS_ENDPOINTS.length - 1 &&
      isRetriableOverpassError(err)
    ) {
      return postOverpassQuery(body, endpointIndex + 1, overpassErrors);
    }

    err.overpass_errors = overpassErrors;
    throw err;
  }
};

const fetchOsmPlaces = async (origin, query, category, radiusKm, limit) => {
  if (!origin) {
    return [];
  }

  const profiles = getOsmSearchProfiles(query, category);

  if (!profiles.length) {
    return [];
  }

  const searchRadiusKm = getOsmSearchRadiusKm(profiles, radiusKm);
  const radiusMeters = Math.round(searchRadiusKm * 1000);
  const overpassLimit = Math.min(
    Math.max((limit || DEFAULT_LIMIT) * 3, DEFAULT_LIMIT),
    OSM_LIVE_SEARCH_MAX_LIMIT,
  );
  const overpassQuery = buildOsmOverpassQuery(
    profiles,
    radiusMeters,
    origin,
    overpassLimit,
  );
  const body = new URLSearchParams();
  body.set('data', overpassQuery);

  const response = await postOverpassQuery(body);

  const elements = Array.isArray(response.data?.elements)
    ? response.data.elements
    : [];
  const placeMap = new Map();

  elements.forEach((element) => {
    const profile = getOsmElementProfile(element, profiles);
    const place = toOsmPlace(element, origin, profile);

    if (!place) {
      return;
    }

    const existingPlace = placeMap.get(place.id);

    if (!existingPlace || compareDistance(place, existingPlace) < 0) {
      placeMap.set(place.id, place);
    }
  });

  return Array.from(placeMap.values())
    .sort((a, b) => compareDistance(a, b))
    .slice(0, overpassLimit);
};

const toPublicPlace = (place, origin) => {
  const plain = place.get({ plain: true });
  const latitude = toNumber(plain.latitude);
  const longitude = toNumber(plain.longitude);
  const distanceKm =
    origin && latitude !== null && longitude !== null
      ? calculateDistanceKm(origin.lat, origin.lng, latitude, longitude)
      : null;
  const offerings = (plain.offerings || []).map(toPublicOffering);
  const publicPlace = {
    id: plain.id,
    name: plain.name,
    short_description: plain.short_description,
    full_description: plain.full_description,
    address: plain.address,
    city: plain.city,
    province: plain.province,
    latitude,
    longitude,
    phone_number: plain.phone_number,
    whatsapp_number: plain.whatsapp_number,
    website_url: plain.website_url,
    google_maps_url: plain.google_maps_url,
    price_level: plain.price_level,
    average_price: toNumber(plain.average_price),
    rating_average: toNumber(plain.rating_average),
    rating_count: plain.rating_count || 0,
    status: plain.status,
    is_verified: Boolean(plain.is_verified),
    updatedAt: plain.updatedAt,
    category: plain.category
      ? {
          id: plain.category.id,
          name: plain.category.name,
          slug: plain.category.slug,
          color_hex: plain.category.color_hex,
          description: plain.category.description,
        }
      : null,
    distance_km: distanceKm === null ? null : Number(distanceKm.toFixed(2)),
    offerings,
    offerings_summary: summarizeOfferings(offerings),
  };

  publicPlace.live_status = calculateLiveStatus(publicPlace);

  return publicPlace;
};

const buildCategoryWhere = (category) => {
  if (!category) {
    return { is_active: true };
  }

  const categoryFilters = [
    { slug: category },
    { name: { [Op.iLike]: `%${category}%` } },
  ];

  if (
    /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(
      category,
    )
  ) {
    categoryFilters.unshift({ id: category });
  }

  return {
    is_active: true,
    [Op.or]: categoryFilters,
  };
};

const offeringsInclude = {
  model: db.place_offerings,
  as: 'offerings',
  attributes: [
    'id',
    'name',
    'description',
    'offering_type',
    'price',
    'stock_status',
    'stock_quantity',
    'is_verified',
    'last_stock_update',
  ],
  where: { is_active: true },
  required: false,
  separate: true,
  order: [
    ['is_verified', 'DESC'],
    ['stock_status', 'ASC'],
    ['name', 'ASC'],
  ],
};

const buildDistanceBuckets = (places) =>
  DISTANCE_BUCKETS.map((maxKm) => {
    const count = places.filter(
      (place) => place.distance_km !== null && place.distance_km <= maxKm,
    ).length;
    const zone = getRadiusZone(maxKm, maxKm);

    return {
      radius_km: maxKm,
      label: maxKm === GLOBAL_RADIUS_KM ? '500+ km' : `${maxKm} km`,
      zone_label: zone.label,
      count,
    };
  });

const buildAiRecommendation = (place, query) => {
  const topOffering = place.offerings_summary?.top_available?.[0];
  const reasonParts = [];

  if (topOffering) {
    reasonParts.push(
      `${topOffering.name} ${topOffering.stock_label.toLowerCase()}`,
    );
  }

  if (place.is_verified) {
    reasonParts.push('tempat terverifikasi');
  }

  if (place.distance_km !== null && place.distance_km !== undefined) {
    reasonParts.push(`${place.distance_km} km dari titik Anda`);
  }

  if (place.rating_average) {
    reasonParts.push(`rating ${place.rating_average}`);
  }

  return {
    label: query ? `Cocok untuk “${query}”` : 'Rekomendasi lokal',
    reason: reasonParts.length
      ? `Direkomendasikan karena ${reasonParts.join(', ')}.`
      : 'Direkomendasikan berdasarkan kategori, reputasi, aktivitas data, dan kedekatan lokasi.',
    source: 'AI Recommendation MVP berbasis GeoScore dan sinyal lokal.',
  };
};

const buildTrending = (places) => {
  const categoryMap = new Map();
  const availableOfferings = [];
  let openNow = 0;

  places.forEach((place) => {
    const categoryName = place.category?.name || 'Tempat lokal';
    categoryMap.set(categoryName, (categoryMap.get(categoryName) || 0) + 1);

    if (place.live_status?.status === 'open') {
      openNow += 1;
    }

    (place.offerings || []).filter(isAvailableOffering).forEach((offering) => {
      availableOfferings.push({
        name: offering.name,
        place_name: place.name,
        stock_label: offering.stock_label,
        type: offering.offering_type,
      });
    });
  });

  return {
    local_topics: Array.from(categoryMap.entries())
      .sort((a, b) => b[1] - a[1])
      .slice(0, 4)
      .map(([name, count]) => ({
        name,
        count,
        label: `${count} listing aktif`,
      })),
    products: availableOfferings.slice(0, 6),
    events: [
      'Promo lokal dan event sekitar siap diindeks dari panel admin.',
      'Crawler berita/video/PDF bisa ditambahkan sebagai tahap berikutnya.',
    ],
    live: {
      open_now: openNow,
      total: places.length,
    },
  };
};

const loadPublicPlaces = async (category) =>
  db.places.findAll({
    where: {
      status: 'published',
    },
    include: [
      {
        model: db.place_categories,
        as: 'category',
        attributes: ['id', 'name', 'slug', 'description', 'color_hex'],
        where: buildCategoryWhere(category),
        required: true,
      },
      offeringsInclude,
    ],
    limit: CANDIDATE_LIMIT,
    order: [
      ['is_verified', 'DESC'],
      ['rating_average', 'DESC'],
      ['name', 'ASC'],
    ],
  });

router.get(
  '/places/categories',
  wrapAsync(async (req, res) => {
    const categories = await db.place_categories.findAll({
      attributes: [
        'id',
        'name',
        'slug',
        'description',
        'color_hex',
        'is_active',
      ],
      where: { is_active: true },
      order: [['name', 'ASC']],
    });

    res.status(200).send({ rows: categories });
  }),
);

router.get(
  '/places/:id',
  wrapAsync(async (req, res) => {
    const lat = parseCoordinate(req.query.lat, 'Latitude');
    const lng = parseCoordinate(req.query.lng, 'Longitude');
    const query = String(req.query.q || '').trim();
    const queryIntent = analyzeHyperlocalIntent(query);
    const radiusKm =
      queryIntent.requestedRadiusKm || parseRadius(req.query.radiusKm);
    const origin = lat !== null && lng !== null ? { lat, lng } : null;

    const place = await db.places.findOne({
      where: {
        id: req.params.id,
        status: 'published',
      },
      include: [
        {
          model: db.place_categories,
          as: 'category',
          attributes: ['id', 'name', 'slug', 'description', 'color_hex'],
          where: { is_active: true },
          required: true,
        },
        offeringsInclude,
      ],
    });

    if (!place) {
      const error = new Error(
        'Tempat tidak ditemukan. Pastikan tempat sudah berstatus published.',
      );
      error.code = 404;
      throw error;
    }

    const publicPlace = toPublicPlace(place, origin);
    const geoScore = calculateGeoScore(publicPlace, query, radiusKm);

    res.status(200).send({
      ...publicPlace,
      search_score: geoScore.value,
      geo_score: geoScore.value,
      geo_score_breakdown: geoScore.components,
      geo_score_formula: GEO_SCORE_FORMULA,
      radius_zone: getRadiusZone(publicPlace.distance_km, radiusKm),
      query_intent: queryIntent,
      ai_recommendation: buildAiRecommendation(publicPlace, query),
    });
  }),
);

router.get(
  '/places',
  wrapAsync(async (req, res) => {
    const query = String(req.query.q || '').trim();
    const category = String(req.query.category || '').trim();
    const lat = parseCoordinate(req.query.lat, 'Latitude');
    const lng = parseCoordinate(req.query.lng, 'Longitude');
    const queryIntent = analyzeHyperlocalIntent(query);
    const originContext = buildSearchOriginContext(lat, lng, query);
    const radiusKm = Math.max(
      queryIntent.requestedRadiusKm || parseRadius(req.query.radiusKm),
      originContext.minimumRadiusKm || 0,
    );
    const origin = originContext.origin;
    const rawLimit = Number(req.query.limit || DEFAULT_LIMIT);
    const limit = Number.isFinite(rawLimit)
      ? Math.min(Math.max(Math.round(rawLimit), 1), MAX_LIMIT)
      : DEFAULT_LIMIT;
    const hasQuery = tokenizeQuery(query).length > 0;

    const externalSourceErrors = [];
    const useFallbackDataset = Boolean(hasQuery || category || isLocalSeoSearchQuery(query));
    const places = await loadPublicPlaces(category);
    const publicPlaces = places.map((place) => toPublicPlace(place, origin));
    const fallbackPlaces = useFallbackDataset
      ? await loadFallbackDatasetPlaces(origin)
      : [];
    const minimumResultTarget = Math.min(MIN_NEAREST_RESULTS, limit);
    let externalPlaces = [];
    let effectiveRadiusKm = radiusKm;
    let expandedForNearest = false;

    const buildCombinedPlaces = () =>
      dedupePlacesById([...externalPlaces, ...fallbackPlaces, ...publicPlaces]);

    const getPlacesWithinRadius = (candidatePlaces, currentRadiusKm) =>
      origin
        ? candidatePlaces.filter(
            (place) =>
              place.distance_km !== null &&
              place.distance_km !== undefined &&
              place.distance_km <= currentRadiusKm,
          )
        : candidatePlaces;

    const buildScoredRows = (candidatePlaces, scoreRadiusKm = effectiveRadiusKm) =>
      candidatePlaces
        .map((place) => {
          const geoScore = calculateGeoScore(place, query, scoreRadiusKm);

          return {
            place,
            geoScore,
          };
        })
        .filter(
          (item) => !hasQuery || item.geoScore.components.relevance > 0,
        )
        .sort((a, b) => sortScoredPlaces(a, b, hasQuery, queryIntent));

    if (origin && (hasQuery || category)) {
      try {
        const externalRadiusKm = queryIntent.nearby
          ? Math.min(Math.max(radiusKm, DEFAULT_RADIUS_KM), 15)
          : Math.min(radiusKm, 25);

        externalPlaces = await fetchOsmPlaces(
          origin,
          query,
          category,
          externalRadiusKm,
          Math.max(limit, minimumResultTarget),
        );
      } catch (err) {
        console.log('Gagal memuat data OpenStreetMap', {
          query,
          category,
          message: err.message,
          status: err.response?.status,
          data: err.response?.data,
          overpass_errors: err.overpass_errors,
        });
        externalSourceErrors.push(
          'Data OpenStreetMap gagal dimuat; hasil sekitar lokasi aktif tetap ditampilkan jika tersedia.',
        );
      }
    }

    let combinedPlaces = buildCombinedPlaces();
    let scoredRows = buildScoredRows(
      getPlacesWithinRadius(combinedPlaces, effectiveRadiusKm),
      effectiveRadiusKm,
    );

    if (origin && hasQuery && scoredRows.length < minimumResultTarget) {
      const expansionRadii = [
        Math.min(Math.max(radiusKm * 10, 50), MAX_OSM_EXPANSION_RADIUS_KM),
      ].filter((nextRadiusKm) => nextRadiusKm > effectiveRadiusKm);

      for (const nextRadiusKm of expansionRadii) {
        if (!externalSourceErrors.length && (hasQuery || category)) {
          try {
            const widerExternalPlaces = await fetchOsmPlaces(
              origin,
              query,
              category,
              nextRadiusKm,
              Math.max(limit, minimumResultTarget),
            );

            externalPlaces = dedupePlacesById([
              ...externalPlaces,
              ...widerExternalPlaces,
            ]);
            combinedPlaces = buildCombinedPlaces();
          } catch (err) {
            console.log('Gagal memperluas data OpenStreetMap', {
              query,
              category,
              radiusKm: nextRadiusKm,
              message: err.message,
              status: err.response?.status,
              data: err.response?.data,
              overpass_errors: err.overpass_errors,
            });
            externalSourceErrors.push(
              'Radius OpenStreetMap tidak bisa diperluas saat ini; hasil internal tetap ditampilkan jika tersedia.',
            );
          }
        }

        const expandedRows = buildScoredRows(
          getPlacesWithinRadius(combinedPlaces, nextRadiusKm),
          nextRadiusKm,
        );

        if (expandedRows.length > scoredRows.length) {
          scoredRows = expandedRows;
          effectiveRadiusKm = nextRadiusKm;
          expandedForNearest = effectiveRadiusKm > radiusKm;
        }

        if (
          scoredRows.length >= minimumResultTarget ||
          externalSourceErrors.length
        ) {
          break;
        }
      }
    }

    if (origin && hasQuery && scoredRows.length < minimumResultTarget) {
      const allDistanceRows = buildScoredRows(
        combinedPlaces.filter(
          (place) =>
            place.distance_km !== null && place.distance_km !== undefined,
        ),
        GLOBAL_RADIUS_KM,
      );

      if (allDistanceRows.length > scoredRows.length) {
        scoredRows = allDistanceRows;
        effectiveRadiusKm = GLOBAL_RADIUS_KM;
        expandedForNearest = effectiveRadiusKm > radiusKm;
      }
    }

    const rows = scoredRows.slice(0, limit).map((item) => ({
      ...item.place,
      search_score: item.geoScore.value,
      geo_score: item.geoScore.value,
      geo_score_breakdown: item.geoScore.components,
      radius_zone: getRadiusZone(item.place.distance_km, effectiveRadiusKm),
      ai_recommendation: buildAiRecommendation(item.place, query),
    }));
    const nearbyIndexedPlaces = origin
      ? getPlacesWithinRadius(
          combinedPlaces,
          Math.max(effectiveRadiusKm, radiusKm, 100),
        )
      : combinedPlaces;

    res.status(200).send({
      rows,
      count: scoredRows.length,
      radius_km: origin ? radiusKm : null,
      effective_radius_km: origin ? effectiveRadiusKm : null,
      radius_zone: getRadiusZone(null, effectiveRadiusKm),
      query_intent: queryIntent,
      search_origin: originContext.meta,
      radius_zones: RADIUS_ZONES,
      distance_buckets: buildDistanceBuckets(nearbyIndexedPlaces),
      filtered_by_radius: Boolean(origin) && !expandedForNearest,
      expanded_for_nearest: expandedForNearest,
      external_sources: [
        ...(externalPlaces.length ? ['openstreetmap'] : []),
        ...(fallbackPlaces.length ? ['geoseek_csv'] : []),
      ],
      external_source_errors: externalSourceErrors,
      total_candidates: nearbyIndexedPlaces.length,
      geo_score_formula: GEO_SCORE_FORMULA,
      trending: buildTrending(rows),
      recommendation_engine: 'rules_based_geo_score_mvp_min_10_expansion',
    });
  }),
);

router.get(
  '/trending',
  wrapAsync(async (req, res) => {
    const lat = parseCoordinate(req.query.lat, 'Latitude');
    const lng = parseCoordinate(req.query.lng, 'Longitude');
    const origin = lat !== null && lng !== null ? { lat, lng } : null;
    const places = await loadPublicPlaces('');
    const publicPlaces = places.map((place) => toPublicPlace(place, origin));

    res.status(200).send(buildTrending(publicPlaces));
  }),
);

router.use('/', commonErrorHandler);

module.exports = router;
