/**
 * GNM — Garda Națională de Mediu.
 *
 * Scrapes the WordPress RSS feed at gnm.ro/feed/ (paginated, ~36 pages × 10
 * items = ~358 articles) and persists every press release to gnm.comunicate.
 * Articles that look like enforcement actions get is_enforcement=true and a
 * regex pass extracts (firm_name, fine_lei) candidate tuples into
 * gnm.amenzi_extrase.
 *
 * NOTE: source publishes only aggregate stats — see 035_gnm.sql header for
 * full investigation log. This scraper captures the headlines, not the long
 * tail.
 *
 * Run:
 *   npx tsx src/scrape-gnm.ts                 # full backfill (~36 pages)
 *   npx tsx src/scrape-gnm.ts --max-pages=2   # smoke test (20 articles)
 *   npx tsx src/scrape-gnm.ts --since-days=7  # incremental (skip older)
 */
import { Pool } from 'pg';
import { createHash } from 'crypto';

const FEED = 'https://www.gnm.ro/feed/';
const USER_AGENT = 'gov-agreg/1.0 vreaudigital.ro (contact: m.tarau@beletage.ro)';

const args = process.argv.slice(2);
const argMaxPages = parseInt(args.find((a) => a.startsWith('--max-pages='))?.split('=')[1] || '0');
const argSinceDays = parseInt(args.find((a) => a.startsWith('--since-days='))?.split('=')[1] || '0');

const pool = new Pool({ connectionString: process.env.DATABASE_URL, max: 4 });

interface RssItem {
  guid: string;
  url: string;
  titlu: string;
  publicat_la: Date | null;
  autor: string | null;
  categorii: string[];
  continut_html: string;
  continut_text: string;
}

interface ExtractedViolator {
  contravenient_name: string;
  judet: string | null;
  fapta: string | null;
  suma_lei: number | null;
  suma_eur: number | null;
  suma_aggregate: boolean;
  context_snippet: string;
}

interface IngestStats {
  articles_seen: number;
  articles_inserted: number;
  articles_updated: number;
  articles_skipped: number;
  enforcement_flagged: number;
  violators_extracted: number;
}

// ── Helpers ─────────────────────────────────────────────────────────────────
function sha1Hex(s: string): string {
  return createHash('sha1').update(s).digest('hex');
}

function decodeHtml(s: string): string {
  return s
    .replace(/&nbsp;/g, ' ')
    .replace(/&amp;/g, '&')
    .replace(/&lt;/g, '<')
    .replace(/&gt;/g, '>')
    .replace(/&quot;/g, '"')
    .replace(/&apos;/g, "'")
    .replace(/&#39;/g, "'")
    .replace(/&#8211;/g, '-')
    .replace(/&#8212;/g, '-')
    .replace(/&#8216;/g, "'")
    .replace(/&#8217;/g, "'")
    .replace(/&#8220;/g, '"')
    .replace(/&#8221;/g, '"')
    .replace(/&hellip;/g, '...')
    .replace(/&#(\d+);/g, (_, n) => String.fromCharCode(parseInt(n, 10)));
}

function stripHtml(html: string): string {
  return decodeHtml(
    html
      .replace(/<\s*br\s*\/?>/gi, '\n')
      .replace(/<\s*\/p\s*>/gi, '\n')
      .replace(/<[^>]+>/g, ' '),
  )
    .split('\n')
    .map((l) => l.replace(/\s+/g, ' ').trim())
    .filter((l) => l.length > 0)
    .join('\n');
}

function unwrapCdata(s: string): string {
  return s.replace(/^<!\[CDATA\[/, '').replace(/\]\]>$/, '');
}

async function sleep(ms: number): Promise<void> {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

// ── HTTP fetch with retries ────────────────────────────────────────────────
async function fetchWithRetry(url: string, attempt = 1): Promise<string> {
  const maxAttempts = 4;
  try {
    const res = await fetch(url, {
      headers: {
        'User-Agent': USER_AGENT,
        Accept: 'application/rss+xml, application/xml, text/xml, */*',
      },
      signal: AbortSignal.timeout(45000),
    });
    if (!res.ok) throw new Error(`HTTP ${res.status} ${url}`);
    return await res.text();
  } catch (e) {
    if (attempt >= maxAttempts) throw e;
    const backoff = 1500 * attempt;
    console.warn(`[gnm] fetch ${url} attempt ${attempt} failed: ${e instanceof Error ? e.message : e}; retry in ${backoff}ms`);
    await sleep(backoff);
    return fetchWithRetry(url, attempt + 1);
  }
}

// ── RSS parser ─────────────────────────────────────────────────────────────
function parseRss(xml: string): RssItem[] {
  const items: RssItem[] = [];
  const itemRe = /<item>([\s\S]*?)<\/item>/g;
  let m: RegExpExecArray | null;
  while ((m = itemRe.exec(xml)) !== null) {
    const block = m[1];

    function field(name: string, isCdata = false): string | null {
      const re = new RegExp(`<${name}[^>]*>([\\s\\S]*?)<\\/${name}>`, 'i');
      const x = block.match(re);
      if (!x) return null;
      let v = x[1];
      if (isCdata) v = unwrapCdata(v);
      return v.trim() || null;
    }

    const guid = field('guid');
    const url = field('link');
    const titluRaw = field('title', true);
    const pubDateRaw = field('pubDate');
    const autorRaw = field('dc:creator', true);
    const contentRaw = field('content:encoded', true);

    if (!guid || !url || !titluRaw) continue;

    const titlu = decodeHtml(titluRaw).trim();
    const autor = autorRaw ? decodeHtml(autorRaw).trim() : null;
    const continut_html = contentRaw ? decodeHtml(contentRaw) : '';
    const continut_text = continut_html ? stripHtml(continut_html) : '';

    let publicat_la: Date | null = null;
    if (pubDateRaw) {
      const d = new Date(pubDateRaw);
      if (!isNaN(d.getTime())) publicat_la = d;
    }

    // Multiple <category> tags possible
    const categorii: string[] = [];
    const catRe = /<category[^>]*>(?:<!\[CDATA\[)?([\s\S]*?)(?:\]\]>)?<\/category>/gi;
    let cm: RegExpExecArray | null;
    while ((cm = catRe.exec(block)) !== null) {
      const c = decodeHtml(cm[1]).replace(/]]>$/, '').trim();
      if (c) categorii.push(c);
    }

    items.push({ guid, url, titlu, publicat_la, autor, categorii, continut_html, continut_text });
  }
  return items;
}

// ── Enforcement detection + violator extraction ────────────────────────────
const ENFORCEMENT_RE = /\b(amenz|amend[ăa]|sanc[țt]iun|sancționat|sistare|confiscat|sesizare\s+penal)\b/i;

const JUDETE = [
  'Alba', 'Arad', 'Argeș', 'Arges', 'Bacău', 'Bacau', 'Bihor', 'Bistrița-Năsăud',
  'Bistrita-Nasaud', 'Botoșani', 'Botosani', 'Brașov', 'Brasov', 'Brăila', 'Braila',
  'Buzău', 'Buzau', 'Caraș-Severin', 'Caras-Severin', 'Călărași', 'Calarasi', 'Cluj',
  'Constanța', 'Constanta', 'Covasna', 'Dâmbovița', 'Dambovita', 'Dolj', 'Galați',
  'Galati', 'Giurgiu', 'Gorj', 'Harghita', 'Hunedoara', 'Ialomița', 'Ialomita', 'Iași',
  'Iasi', 'Ilfov', 'Maramureș', 'Maramures', 'Mehedinți', 'Mehedinti', 'Mureș',
  'Mures', 'Neamț', 'Neamt', 'Olt', 'Prahova', 'Sălaj', 'Salaj', 'Satu Mare', 'Sibiu',
  'Suceava', 'Teleorman', 'Timiș', 'Timis', 'Tulcea', 'Vâlcea', 'Valcea', 'Vaslui',
  'Vrancea', 'București', 'Bucuresti',
];

function detectJudet(text: string): string | null {
  // Diacritic-insensitive match — pick first hit ranked by length (specificity).
  const t = text;
  let best: string | null = null;
  let bestLen = 0;
  for (const j of JUDETE) {
    const re = new RegExp(`\\b${j.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&')}\\b`, 'i');
    if (re.test(t) && j.length > bestLen) {
      best = j;
      bestLen = j.length;
    }
  }
  return best;
}

/**
 * Parses Romanian-formatted numbers like "340.000", "2.460.000", "1.74 milioane",
 * "12,5 mii". Returns lei value; caller decides if EUR conversion needed.
 */
function parseRoNumber(s: string): number | null {
  let str = s.trim().toLowerCase();
  let multiplier = 1;
  if (/milio(?:n|ane)/.test(str)) {
    multiplier = 1_000_000;
    str = str.replace(/milio(?:n|ane).*$/, '').trim();
  } else if (/\bmii\b/.test(str)) {
    multiplier = 1_000;
    str = str.replace(/\bmii\b.*$/, '').trim();
  }
  // Romanian: dot = thousand separator, comma = decimal
  // "340.000" → 340000;  "1,74" → 1.74
  const cleaned = str.replace(/\./g, '').replace(/,/g, '.');
  const n = parseFloat(cleaned);
  if (!isFinite(n)) return null;
  return n * multiplier;
}

/**
 * Light NER: regex patterns over normalized text to surface violator tuples.
 * Patterns tested:
 *   "amend{ă|at} S.C. NUME S.R.L. cu N (lei|euro)"
 *   "amenda de N lei pentru NUME"
 *   "Operatorul ECONOMIC NUME (...) amend(...)"
 *   "Rafinăria/Stația/Societatea NUME ..."
 * Returns [] if nothing parses cleanly.
 */
function extractViolators(item: RssItem): ExtractedViolator[] {
  const text = item.continut_text;
  if (!text || text.length < 50) return [];

  const out: ExtractedViolator[] = [];
  const seen = new Set<string>();

  function nameKey(n: string): string {
    // Strip trailing legal form, lowercase, collapse whitespace, drop trailing
    // punctuation. Used for dedup so "Retim Ecologic Service SA" and "Retim
    // Ecologic Service" map to the same firm.
    return n
      .toLowerCase()
      .replace(/\s+(s\.?r\.?l|s\.?a|s\.?n\.?c|s\.?c\.?s)\.?$/i, '')
      .replace(/[.,;:!?]+$/, '')
      .replace(/\s+/g, ' ')
      .trim();
  }

  function pushIfNew(v: ExtractedViolator) {
    if (!v.contravenient_name || v.contravenient_name.length < 3) return;
    if (v.contravenient_name.length > 120) return;
    const key = nameKey(v.contravenient_name);
    if (key.length < 3) return;
    // If we already have this firm, merge (prefer richer record: with suma_lei,
    // longer snippet)
    const existing = out.find((o) => nameKey(o.contravenient_name) === key);
    if (existing) {
      if (!existing.suma_lei && v.suma_lei) existing.suma_lei = v.suma_lei;
      if (!existing.suma_eur && v.suma_eur) existing.suma_eur = v.suma_eur;
      if (!existing.judet && v.judet) existing.judet = v.judet;
      if (v.context_snippet.length > existing.context_snippet.length) {
        existing.context_snippet = v.context_snippet;
      }
      // Keep the cleaner name (without trailing legal-form-without-punct
      // breakage, prefer with-dot SA/SRL as canonical).
      if (!/\s+(SA|SRL|SNC|SCS)\b/.test(existing.contravenient_name) && /\s+(SA|SRL|SNC|SCS)\b/.test(v.contravenient_name)) {
        existing.contravenient_name = v.contravenient_name;
      }
      return;
    }
    seen.add(key);
    out.push(v);
  }

  const judet_global = detectJudet(item.titlu) || detectJudet(text);

  // ── Pattern A: company name with legal-form suffix ──
  // Two flavors:
  //  (1) "S.C./SC NAME S.R.L./SRL/S.A./SA" with mandatory prefix
  //  (2) "NAME SRL" / "NAME SA" — bare suffix, name = 1-5 capitalized tokens
  //      preceding the suffix (greedy from right). Used for "Retim Ecologic
  //      Service SA", "Eco Sud SRL", etc.
  const scRe = /\b(?:(?:S\.?C\.?|Societatea)\s+)?((?:[A-ZĂÂÎȘȚ][A-Za-zĂÂÎȘȚăâîșț0-9\-&'.]+(?:\s+[A-ZĂÂÎȘȚ][A-Za-zĂÂÎȘȚăâîșț0-9\-&'.]+){0,4}))\s+(S\.?R\.?L\.?|S\.?A\.?|S\.?N\.?C\.?|S\.?C\.?S\.?)\b/g;
  let scm: RegExpExecArray | null;
  while ((scm = scRe.exec(text)) !== null) {
    const baseName = scm[1].trim();
    const suffix = scm[2].replace(/\./g, '').toUpperCase();
    // Reject junk where the captured name is a stop-phrase or is GNM itself
    const lc = baseName.toLowerCase();
    if (/^(garda|g\.?n\.?m|comisariat|comisarul|ministerul|prim[ăa]ria|rom[âa]nia|jandarmer|poli[țt]ia|gnm|inspectorat|consiliul|comitetul|grupul|colegiul|firma|societatea|compania|prefectur|guvernul)\b/.test(lc)) continue;
    // Reject very generic single-word names
    if (!/[A-ZĂÂÎȘȚ]/.test(baseName) || baseName.length < 4) continue;
    const fullName = (baseName + ' ' + suffix).replace(/\s+/g, ' ').trim();
    const name = fullName;
    const start = Math.max(0, scm.index - 200);
    const end = Math.min(text.length, scm.index + scm[0].length + 200);
    const window = text.slice(start, end);

    // Look for "X lei" or "X.XXX lei" or "X milioane lei"
    const sumRe = /(\d+(?:[.,]\d+)*\s*(?:milio(?:n|ane)|mii)?\s*)\s*lei/i;
    const sm = window.match(sumRe);
    let suma_lei: number | null = null;
    if (sm) suma_lei = parseRoNumber(sm[1]);

    // Snippet around name (at most 240 chars)
    const sStart = Math.max(0, scm.index - 60);
    const sEnd = Math.min(text.length, scm.index + scm[0].length + 180);
    const snippet = text.slice(sStart, sEnd).replace(/\s+/g, ' ').trim();

    pushIfNew({
      contravenient_name: name,
      judet: detectJudet(window) || judet_global,
      fapta: null,
      suma_lei,
      suma_eur: null,
      suma_aggregate: false,
      context_snippet: snippet,
    });
  }

  // ── Pattern B': "operat de NAME" / "administrat de NAME" (operator clauses) ──
  // Captures up to 5 capitalized tokens, optional "SA"/"SRL" trailing.
  // Note: name is bounded to capitalized tokens only; trailing legal-form is
  // included greedily. Stop at sentence-end (./,/\n) to avoid runaway capture
  // crossing into next sentence.
  const operatorRe = /\b(?:operat|administrat|exploatat|gestionat|de[țt]inut)\s+de\s+([A-ZĂÂÎȘȚ][A-Za-zĂÂÎȘȚăâîșț0-9\-&']+(?:\s+[A-ZĂÂÎȘȚ][A-Za-zĂÂÎȘȚăâîșț0-9\-&']+){0,4}(?:\s+S\.?[ARN]\.?(?:[CL]\.?)?)?)(?=[\s.,;:\n!?]|$)/g;
  let opm: RegExpExecArray | null;
  while ((opm = operatorRe.exec(text)) !== null) {
    const name = opm[1].trim();
    if (Array.from(seen).some((k) => k.startsWith(name.toLowerCase()))) continue;
    if (name.length < 4 || name.length > 100) continue;

    const start = Math.max(0, opm.index - 250);
    const end = Math.min(text.length, opm.index + opm[0].length + 250);
    const window = text.slice(start, end);
    const sumRe = /(\d+(?:[.,]\d+)*\s*(?:milio(?:n|ane)|mii)?\s*)\s*lei/i;
    const sm = window.match(sumRe);
    const suma_lei = sm ? parseRoNumber(sm[1]) : null;

    const sStart = Math.max(0, opm.index - 60);
    const sEnd = Math.min(text.length, opm.index + opm[0].length + 200);
    const snippet = text.slice(sStart, sEnd).replace(/\s+/g, ' ').trim();

    pushIfNew({
      contravenient_name: name,
      judet: detectJudet(window) || judet_global,
      fapta: null,
      suma_lei,
      suma_eur: null,
      suma_aggregate: false,
      context_snippet: snippet,
    });
  }

  // ── Pattern B: "Rafinăria/Stația/Compania/Societatea NUME" + amount nearby ──
  // The captured token must start with capital and look like a proper noun
  // (1-3 capitalized words after the prefix).
  const namedRe = /\b(Rafin[ăa]ria|Sta[țt]ia|Compania|Societatea|Ferma|Cariera|Balastiera|Depozitul|Operatorul)\s+([A-ZĂÂÎȘȚ][A-Za-zĂÂÎȘȚăâîșț0-9\-]+(?:\s+[A-ZĂÂÎȘȚ][A-Za-zĂÂÎȘȚăâîșț0-9\-]+){0,2})\b/g;
  let nm: RegExpExecArray | null;
  while ((nm = namedRe.exec(text)) !== null) {
    const name = (nm[1] + ' ' + nm[2]).trim();
    // Skip if same firm already captured by Pattern A
    if (Array.from(seen).some((k) => k.startsWith(name.toLowerCase()))) continue;

    const start = Math.max(0, nm.index - 200);
    const end = Math.min(text.length, nm.index + nm[0].length + 250);
    const window = text.slice(start, end);

    const sumRe = /(\d+(?:[.,]\d+)*\s*(?:milio(?:n|ane)|mii)?\s*)\s*lei/i;
    const sumEurRe = /(\d+(?:[.,]\d+)*\s*(?:milio(?:n|ane)|mii)?\s*)\s*(?:euro|EUR|€)/i;
    let suma_lei: number | null = null;
    let suma_eur: number | null = null;
    const sm = window.match(sumRe);
    if (sm) suma_lei = parseRoNumber(sm[1]);
    const em = window.match(sumEurRe);
    if (em) suma_eur = parseRoNumber(em[1]);

    // Aggregate flag — present if the window contains "total", "cumulat",
    // "în ultimul an", "operatorii", "rafinăriile" plural.
    const aggregate = /\b(total|cumulat|operatorii|rafin[ăa]riile|fermele|carierele|balastierele|în ultimul an|în 2025|în 2024)\b/i.test(window);

    const sStart = Math.max(0, nm.index - 60);
    const sEnd = Math.min(text.length, nm.index + nm[0].length + 180);
    const snippet = text.slice(sStart, sEnd).replace(/\s+/g, ' ').trim();

    pushIfNew({
      contravenient_name: name,
      judet: detectJudet(window) || judet_global,
      fapta: null,
      suma_lei,
      suma_eur,
      suma_aggregate: aggregate,
      context_snippet: snippet,
    });
  }

  return out;
}

/**
 * Best-effort total amount mentioned in the article — sum of all "N lei"
 * tokens, capped at the largest single mention (avoids double-counting when
 * the article quotes both per-firm and aggregate figures).
 */
function extractTotalAmenziLei(text: string): number | null {
  if (!text) return null;
  const re = /(\d+(?:[.,]\d+)*\s*(?:milio(?:n|ane)|mii)?\s*)\s*lei/gi;
  let m: RegExpExecArray | null;
  let largest = 0;
  while ((m = re.exec(text)) !== null) {
    const v = parseRoNumber(m[1]);
    if (v && v > largest) largest = v;
  }
  return largest > 0 ? largest : null;
}

// ── Ingest ──────────────────────────────────────────────────────────────────
async function ingestItem(item: RssItem, stats: IngestStats): Promise<void> {
  const client = await pool.connect();
  try {
    const is_enforcement = ENFORCEMENT_RE.test(item.titlu) || ENFORCEMENT_RE.test(item.continut_text);
    const total_amenzi_lei = is_enforcement ? extractTotalAmenziLei(item.continut_text) : null;
    const raw_hash = sha1Hex(item.continut_text);

    const result = await client.query(
      `INSERT INTO gnm.comunicate (
         guid, url, titlu, publicat_la, autor, categorii,
         continut_html, continut_text, is_enforcement, total_amenzi_lei,
         raw_hash, fetched_at
       )
       VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,now())
       ON CONFLICT (guid) DO UPDATE SET
         titlu = EXCLUDED.titlu,
         publicat_la = COALESCE(EXCLUDED.publicat_la, gnm.comunicate.publicat_la),
         autor = COALESCE(EXCLUDED.autor, gnm.comunicate.autor),
         categorii = EXCLUDED.categorii,
         continut_html = EXCLUDED.continut_html,
         continut_text = EXCLUDED.continut_text,
         is_enforcement = EXCLUDED.is_enforcement,
         total_amenzi_lei = EXCLUDED.total_amenzi_lei,
         raw_hash = EXCLUDED.raw_hash,
         fetched_at = now()
       WHERE gnm.comunicate.raw_hash IS DISTINCT FROM EXCLUDED.raw_hash
       RETURNING id, (xmax = 0) AS inserted`,
      [
        item.guid,
        item.url,
        item.titlu,
        item.publicat_la,
        item.autor,
        item.categorii,
        item.continut_html,
        item.continut_text,
        is_enforcement,
        total_amenzi_lei,
        raw_hash,
      ],
    );

    if (result.rowCount === 0) {
      // No-op (hash unchanged) — fetch existing id to refresh extracted rows
      // only if needed. Skip extraction work entirely.
      stats.articles_skipped++;
      return;
    }

    const com_id: number = result.rows[0].id;
    const inserted: boolean = result.rows[0].inserted;
    if (inserted) stats.articles_inserted++;
    else stats.articles_updated++;

    if (is_enforcement) {
      stats.enforcement_flagged++;

      // Wipe + re-extract violators on every change (idempotent semantics)
      await client.query(`DELETE FROM gnm.amenzi_extrase WHERE comunicat_id = $1`, [com_id]);
      const violators = extractViolators(item);
      for (const v of violators) {
        await client.query(
          `INSERT INTO gnm.amenzi_extrase (
             comunicat_id, contravenient_name, contravenient_name_norm,
             contravenient_cui, cui_match_method, cui_match_score, matched_at,
             judet, fapta, suma_lei, suma_eur, suma_aggregate, context_snippet, fetched_at
           )
           VALUES (
             $1, $2,
             firms.normalize_company_name($2),
             NULL, NULL, NULL, NULL,
             $3, $4, $5, $6, $7, $8, now()
           )`,
          [
            com_id,
            v.contravenient_name,
            v.judet,
            v.fapta,
            v.suma_lei,
            v.suma_eur,
            v.suma_aggregate,
            v.context_snippet,
          ],
        );
        stats.violators_extracted++;
      }
    }
  } finally {
    client.release();
  }
}

// ── Main ───────────────────────────────────────────────────────────────────
async function main(): Promise<void> {
  const t0 = Date.now();
  const stats: IngestStats = {
    articles_seen: 0,
    articles_inserted: 0,
    articles_updated: 0,
    articles_skipped: 0,
    enforcement_flagged: 0,
    violators_extracted: 0,
  };

  const sinceCutoff = argSinceDays > 0 ? new Date(Date.now() - argSinceDays * 86400_000) : null;

  // Discover total pages by walking until we get an empty page
  // RSS gives 10 items/page; gnm.ro currently has ~36 pages. Cap at 50 for safety.
  const maxPages = argMaxPages > 0 ? argMaxPages : 50;

  let page = 1;
  let stopped = false;
  while (page <= maxPages && !stopped) {
    const url = page === 1 ? FEED : `${FEED}?paged=${page}`;
    let xml: string;
    try {
      xml = await fetchWithRetry(url);
    } catch (e) {
      console.error(`[gnm] page ${page} fetch failed: ${e instanceof Error ? e.message : e}`);
      break;
    }

    const items = parseRss(xml);
    if (items.length === 0) {
      console.log(`[gnm] page ${page}: 0 items, end of feed`);
      break;
    }

    let pageProcessed = 0;
    for (const it of items) {
      stats.articles_seen++;
      if (sinceCutoff && it.publicat_la && it.publicat_la < sinceCutoff) {
        stopped = true; // RSS is reverse-chrono; once we're past the cutoff, stop entirely
        break;
      }
      try {
        await ingestItem(it, stats);
        pageProcessed++;
      } catch (e) {
        console.error(`[gnm] ingest fail guid=${it.guid}: ${e instanceof Error ? e.message : e}`);
      }
    }

    console.log(`[gnm] page ${page}: ${items.length} items, processed ${pageProcessed}`);
    page++;
    await sleep(800); // be polite
  }

  // Log to gnm.scrape_log
  const duration_ms = Date.now() - t0;
  await pool.query(
    `INSERT INTO gnm.scrape_log (scraper, source_url, rows_seen, rows_inserted, rows_updated, rows_skipped, duration_ms, started_at)
     VALUES ('rss_feed', $1, $2, $3, $4, $5, $6, $7)`,
    [
      FEED,
      stats.articles_seen,
      stats.articles_inserted,
      stats.articles_updated,
      stats.articles_skipped,
      duration_ms,
      new Date(t0),
    ],
  );

  console.log(
    `[gnm] DONE seen=${stats.articles_seen} inserted=${stats.articles_inserted} updated=${stats.articles_updated} skipped=${stats.articles_skipped} enforcement=${stats.enforcement_flagged} violators=${stats.violators_extracted} duration=${duration_ms}ms`,
  );

  await pool.end();
}

main().catch((e) => {
  console.error('[gnm] fatal:', e);
  pool.end().finally(() => process.exit(1));
});
