/**
 * search-svc — SearXNG proxy, кэш по query_hash
 * docs/architecture: 02-k3s-microservices-spec.md
 * API: GET /api/v1/search?q=...&categories=...
 * Redis: search:{query_hash} TTL 1h
 */

import Fastify from 'fastify';
import cors from '@fastify/cors';
import Redis from 'ioredis';
import { createHash } from 'crypto';

const PORT = parseInt(process.env.PORT ?? '3001', 10);
const REDIS_URL = process.env.REDIS_URL ?? 'redis://localhost:6379';
const SEARXNG_URL = process.env.SEARXNG_URL ?? 'https://searx.tiekoetter.com';

// @ts-expect-error — ioredis + NodeNext ESM
const redis = new Redis(REDIS_URL);
redis.on('error', () => {});

function queryHash(q: string, extra = ''): string {
  const normalized = q.trim().toLowerCase();
  return createHash('sha256').update(normalized + extra).digest('hex').slice(0, 16);
}

const FALLBACK_INSTANCES = (
  process.env.SEARXNG_FALLBACK_URL?.split(',').map((u) => u.trim()) ?? [
    'https://searx.tiekoetter.com',
    'https://search.sapti.me',
  ]
).filter(Boolean);

async function searchSearxng(
  query: string,
  options: {
    categories?: string | string[];
    engines?: string | string[];
    language?: string;
    pageno?: number;
  } = {}
): Promise<{ results: unknown[]; suggestions?: string[] }> {
  const candidates = [SEARXNG_URL?.trim()?.replace(/\/$/, ''), ...FALLBACK_INSTANCES].filter(
    (u): u is string => !!u && u.length > 0
  );
  if (candidates.length === 0) {
    throw new Error('SearXNG not configured. Set SEARXNG_URL.');
  }

  let lastError: Error | null = null;
  for (const base of candidates) {
    try {
      const params = new URLSearchParams();
      params.set('format', 'json');
      params.set('q', query);
      if (options.categories) {
        params.set('categories', Array.isArray(options.categories) ? options.categories.join(',') : options.categories);
      }
      if (options.engines) {
        params.set('engines', Array.isArray(options.engines) ? options.engines.join(',') : options.engines);
      }
      if (options.language) params.set('language', options.language);
      if (options.pageno != null) params.set('pageno', String(options.pageno));
      const url = `${base.startsWith('http') ? base : 'http://' + base}/search?${params.toString()}`;
      const controller = new AbortController();
      const t = setTimeout(() => controller.abort(), 15000);
      const res = await fetch(url, { signal: controller.signal });
      clearTimeout(t);
      const data = (await res.json()) as { results?: unknown[]; suggestions?: string[] };
      if (!res.ok && (!data.results || data.results.length === 0)) {
        throw new Error(`SearXNG HTTP ${res.status}`);
      }
      return {
        results: data.results ?? [],
        suggestions: data.suggestions ?? [],
      };
    } catch (err) {
      lastError = err instanceof Error ? err : new Error(String(err));
      if (candidates.indexOf(base) < candidates.length - 1) continue;
      throw lastError;
    }
  }
  throw lastError ?? new Error('SearXNG failed');
}

const app = Fastify({ logger: true });

const corsOrigin = process.env.ALLOWED_ORIGINS
  ? process.env.ALLOWED_ORIGINS.split(',').map((s) => s.trim()).filter(Boolean)
  : true;
await app.register(cors, { origin: corsOrigin });

app.get('/health', async () => ({ status: 'ok' }));
app.get('/ready', async () => {
  try {
    await redis.ping();
    return { status: 'ready' };
  } catch {
    return { status: 'degraded', redis: 'unavailable' };
  }
});

app.get('/metrics', async (_req, reply) => {
  reply.header('Content-Type', 'text/plain; charset=utf-8');
  return reply.send(
    '# HELP gooseek_up Service is up (1) or down (0)\n' +
    '# TYPE gooseek_up gauge\n' +
    'gooseek_up 1\n'
  );
});

app.get<{
  Querystring: { q?: string; categories?: string; engines?: string; language?: string; pageno?: string };
}>('/api/v1/search', async (req, reply) => {
  const q = req.query.q?.trim();
  if (!q) {
    return reply.status(400).send({ error: 'q (query) is required' });
  }
  const categories = req.query.categories ?? 'general';
  const engines = req.query.engines ?? '';
  const language = req.query.language ?? '';
  const pageno = parseInt(req.query.pageno ?? '1', 10);
  const hash = queryHash(q, categories + engines + language);
  const cacheKey = `search:${hash}`;
  try {
    const cached = await redis.get(cacheKey);
    if (cached) {
      return reply.header('X-Cache', 'HIT').send(JSON.parse(cached));
    }
  } catch {
    // Redis недоступен
  }
  try {
    const data = await searchSearxng(q, {
      categories,
      engines: engines || undefined,
      language: language || undefined,
      pageno,
    });
    try {
      await redis.setex(cacheKey, 3600, JSON.stringify(data));
    } catch {
      // Redis недоступен — не кэшируем
    }
    return reply.header('X-Cache', 'MISS').send(data);
  } catch (err) {
    req.log.error(err);
    return reply.status(502).send({
      error: err instanceof Error ? err.message : 'Search failed',
    });
  }
});

try {
  await app.listen({ port: PORT, host: '0.0.0.0' });
  console.log(`search-svc listening on :${PORT}`);
} catch (err) {
  console.error(err);
  process.exit(1);
}
