/**
 * Geocode firms.entities addresses via local Photon (127.0.0.1:2322).
 *
 * Strategy:
 *   1. Pull entities where lat IS NULL AND adr_strada IS NOT NULL.
 *   2. Normalize Romanian address fields (strip "Str./Sat/Com./Orş./Mun."
 *      prefixes) and build a clean query string from separate fields rather
 *      than the raw adr_full (which contains floor info, "PARTER", etc).
 *   3. If a UAT centroid lat/lng is known for this firm's siruta, pass it
 *      as location_bias to Photon — dramatically improves disambiguation
 *      when the same street name exists in many towns.
 *   4. Try two queries in fallback: street+number+locality+county, then
 *      locality+county. Pick best by Photon's internal score.
 *   5. Bulk-update firms.entities via UNNEST (single SQL per batch).
 *
 * Photon throughput on satra (10 cores, -Xmx8G):
 *   ~30-100 q/s aggregate at concurrency 50, depending on query complexity.
 *
 * Run: DATABASE_URL=... PHOTON_URL=http://localhost:2322 npx tsx src/geocode-photon.ts
 *      [--limit=N] [--concurrency=N] [--batch=N] [--retry-nomatch]
 *
 * --retry-nomatch: also process firms with geocode_source='photon-nomatch'
 *                  from a previous run (useful after improving the script).
 */
import { Pool } from 'pg';

const PHOTON_URL = process.env.PHOTON_URL || 'http://localhost:2322';

const args = process.argv.slice(2);
const argLimit = parseInt(args.find((a) => a.startsWith('--limit='))?.split('=')[1] || '0');
const argConcurrency = Math.max(1, parseInt(args.find((a) => a.startsWith('--concurrency='))?.split('=')[1] || '50'));
const argBatch = Math.max(10, parseInt(args.find((a) => a.startsWith('--batch='))?.split('=')[1] || '200'));
const argRetryNomatch = args.includes('--retry-nomatch');

const pool = new Pool({
  connectionString: process.env.DATABASE_URL,
  max: Math.max(5, Math.ceil(argConcurrency / 10)),
});

interface Candidate {
  cui: string;
  adr_strada: string | null;
  adr_numar: string | null;
  adr_localitate: string | null;
  adr_judet: string | null;
  bias_lat: number | null;
  bias_lng: number | null;
}

interface PhotonFeature {
  geometry: { coordinates: [number, number] };
  properties: {
    osm_type?: string;
    osm_id?: number;
    country?: string;
    countrycode?: string;
    city?: string;
    county?: string;
    street?: string;
    housenumber?: string;
    postcode?: string;
    name?: string;
    type?: string;
    osm_key?: string;
    osm_value?: string;
  };
}

interface GeoResult {
  cui: string;
  ok: boolean;
  lat?: number;
  lng?: number;
  score?: number;
  precision?: 'housenumber' | 'street' | 'locality';
}

// ── Romanian address normalization ─────────────────────────────────────────
//
// ONRC raw fields look like:
//   adr_strada:    "Str. Mărului"  /  "Bd. Decebal"  /  "Calea Victoriei"
//   adr_numar:     "8-10"  /  "10C"  /  "FN" (fără număr)  /  null
//   adr_localitate:"Sat Fundeni Com. Dobroeşti"  /  "Orş. Popeşti-Leordeni"
//   adr_judet:     "ILFOV"  /  "MUREŞ"  /  "MUNICIPIUL BUCUREŞTI"
//
// Goal: produce a Photon-friendly text like
//   "Mărului 8-10, Dobroeşti, Ilfov, Romania"
// — Photon's RO index uses lower-case tokens with diacritics; the OSM admin
// names are usually town/comuna in title case + județ in title case.

const STREET_PREFIX_RE = /^(?:str|strada|bd|b-?dul|bulevardul|aleea|al|splaiul|spl|calea|cal|soseaua|sos|piata|pta|piaţa|drumul|dr|fundatura|fdt|intrarea|intr|prelungirea|prl)\.?\s+/i;
const LOCALITY_PREFIX_RE = /\b(?:sat|com|comuna|or[şs]|oraşul|orasul|mun|municipiul|loc|localitatea|cartier|cart)\.?\s+/gi;
const FLOOR_NOISE_RE = /,?\s*(?:parter|etaj|et|ap|apt|bl|sc|sector|camera|cam|interior|int)\.?\s*[-\w\d]*/gi;

function normalizeStreet(s: string | null): string | null {
  if (!s) return null;
  return s.replace(STREET_PREFIX_RE, '').replace(/\s+/g, ' ').trim() || null;
}

// "Sat Fundeni Com. Dobroeşti" → "Dobroeşti"
// "Sat Cristeşti Com. Cristeşti" → "Cristeşti"
// "Orş. Popeşti-Leordeni" → "Popeşti-Leordeni"
// "Mun. Cluj-Napoca" → "Cluj-Napoca"
function normalizeLocality(s: string | null): string | null {
  if (!s) return null;
  // If "Com. X" appears, prefer that (the actual administrative locality).
  const comMatch = s.match(/Com\.?\s+([^,]+?)(?:\s*$|,)/i);
  if (comMatch) return comMatch[1].trim();
  // Otherwise strip all known prefixes and take the rest.
  return s.replace(LOCALITY_PREFIX_RE, '').replace(/\s+/g, ' ').trim() || null;
}

function normalizeCounty(s: string | null): string | null {
  if (!s) return null;
  // "MUNICIPIUL BUCUREŞTI" → "București"
  if (/^MUNICIPIUL\s+BUCURE[ŞS]TI/i.test(s)) return 'București';
  // Title-case the rest: "ILFOV" → "Ilfov", "CLUJ" → "Cluj"
  return s.toLowerCase().replace(/(^|\s|-)(\p{L})/gu, (_, sep, c) => sep + c.toUpperCase()).trim() || null;
}

function buildQueries(c: Candidate): string[] {
  const street = normalizeStreet(c.adr_strada);
  const num = c.adr_numar && !/^FN$/i.test(c.adr_numar) ? c.adr_numar : null;
  const locality = normalizeLocality(c.adr_localitate);
  const county = normalizeCounty(c.adr_judet);

  const queries: string[] = [];

  // Most precise: street + number + locality + county
  if (street && num && locality) {
    queries.push([street, num, locality, county].filter(Boolean).join(', '));
  } else if (street && locality) {
    queries.push([street, locality, county].filter(Boolean).join(', '));
  }

  // Fallback: locality + county (street centroid)
  if (locality) {
    queries.push([locality, county].filter(Boolean).join(', '));
  } else if (county) {
    queries.push(county);
  }

  return queries;
}

function precisionFromFeature(f: PhotonFeature): GeoResult['precision'] {
  if (f.properties?.housenumber) return 'housenumber';
  if (f.properties?.street) return 'street';
  return 'locality';
}

async function geocodeOne(c: Candidate): Promise<GeoResult> {
  const queries = buildQueries(c);
  if (queries.length === 0) return { cui: c.cui, ok: false };

  for (const q of queries) {
    try {
      const url = new URL('/api', PHOTON_URL);
      url.searchParams.set('q', q);
      url.searchParams.set('lang', 'en');
      url.searchParams.set('limit', '1');
      // Restrict to RO results (Photon RO extract is RO-only anyway, but defensive).
      url.searchParams.set('osm_tag', '!:railway,boundary');
      // If we know roughly where this firm is (UAT centroid), bias Photon toward it.
      if (c.bias_lat != null && c.bias_lng != null) {
        url.searchParams.set('lat', String(c.bias_lat));
        url.searchParams.set('lon', String(c.bias_lng));
        url.searchParams.set('location_bias_scale', '0.5');
        url.searchParams.set('zoom', '14');
      }

      const res = await fetch(url.toString(), { signal: AbortSignal.timeout(8000) });
      if (!res.ok) continue;
      const data = (await res.json()) as { features?: PhotonFeature[] };
      const f = data.features?.[0];
      if (!f?.geometry?.coordinates) continue;
      const [lng, lat] = f.geometry.coordinates;
      if (typeof lat !== 'number' || typeof lng !== 'number') continue;
      // Romania bounding box sanity check.
      if (lat < 43 || lat > 49 || lng < 19 || lng > 30) continue;
      const precision = precisionFromFeature(f);
      const score = precision === 'housenumber' ? 0.95 : precision === 'street' ? 0.85 : 0.5;
      return { cui: c.cui, ok: true, lat, lng, score, precision };
    } catch {
      // Continue to next fallback query
    }
  }

  return { cui: c.cui, ok: false };
}

// ── Bulk UPDATE via UNNEST (one round-trip per batch instead of N) ─────────

async function persistBatch(results: GeoResult[]) {
  const successes = results.filter((r) => r.ok);
  const failures = results.filter((r) => !r.ok);

  const client = await pool.connect();
  try {
    await client.query('BEGIN');

    if (successes.length > 0) {
      await client.query(
        `
        UPDATE firms.entities e
        SET
          lat = d.lat,
          lng = d.lng,
          geom = ST_SetSRID(ST_MakePoint(d.lng, d.lat), 4326)::geography,
          geocode_source = 'photon',
          geocode_score = d.score,
          geocoded_at = now(),
          updated_at = now()
        FROM (
          SELECT * FROM unnest(
            $1::text[], $2::double precision[], $3::double precision[], $4::real[]
          ) AS u(cui, lat, lng, score)
        ) d
        WHERE e.cui = d.cui
        `,
        [
          successes.map((s) => s.cui),
          successes.map((s) => s.lat!),
          successes.map((s) => s.lng!),
          successes.map((s) => s.score!),
        ],
      );
    }

    if (failures.length > 0) {
      await client.query(
        `
        UPDATE firms.entities
        SET
          geocode_source = 'photon-nomatch',
          geocoded_at = now(),
          updated_at = now()
        WHERE cui = ANY($1::text[])
          AND geocode_source IS NULL
        `,
        [failures.map((f) => f.cui)],
      );
    }

    await client.query('COMMIT');
  } catch (err) {
    await client.query('ROLLBACK').catch(() => {});
    throw err;
  } finally {
    client.release();
  }
}

// ── Main ───────────────────────────────────────────────────────────────────

async function main() {
  const startTs = Date.now();
  const filter = argRetryNomatch
    ? `(geocode_source IS NULL OR geocode_source = 'photon-nomatch')`
    : `geocode_source IS NULL`;

  console.log(`[GEO] Loading candidates (filter: ${filter})...`);
  const totalQ = await pool.query<{ count: string }>(`
    SELECT COUNT(*)::text AS count FROM firms.entities
    WHERE ${filter}
      AND adr_strada IS NOT NULL
      AND adr_judet IS NOT NULL
  `);
  const total = argLimit > 0 ? Math.min(argLimit, parseInt(totalQ.rows[0].count)) : parseInt(totalQ.rows[0].count);
  console.log(`[GEO] Total to geocode: ${total}`);
  console.log(`[GEO] Concurrency=${argConcurrency}, batch=${argBatch}`);

  let processed = 0;
  let matched = 0;
  let housenumber = 0;
  let street = 0;
  let locality = 0;
  let nomatch = 0;

  while (processed < total) {
    // Pull next batch — use SEAP-active priority via LEFT JOIN COUNT.
    // Note: this query does not need to JOIN if we're paginating by reservation;
    // but a fresh ORDER BY each iteration naturally picks unprocessed rows
    // (their geocode_source is still NULL until our updateBatch runs).
    const remaining = total - processed;
    const fetchLimit = Math.min(argBatch, remaining);
    const rows = await pool.query<Candidate>(
      `
      WITH cand AS (
        SELECT e.cui FROM firms.entities e
        WHERE ${filter}
          AND e.adr_strada IS NOT NULL
          AND e.adr_judet IS NOT NULL
        ORDER BY e.cui
        LIMIT $1
      )
      SELECT
        e.cui, e.adr_strada, e.adr_numar, e.adr_localitate, e.adr_judet,
        e.lat AS bias_lat, e.lng AS bias_lng
      FROM firms.entities e
      JOIN cand c ON c.cui = e.cui
      `,
      [fetchLimit],
    );

    if (rows.rows.length === 0) break;

    // Concurrent geocoding via shared queue.
    const queue = [...rows.rows];
    const results: GeoResult[] = [];
    await Promise.all(
      Array.from({ length: argConcurrency }, async () => {
        while (queue.length > 0) {
          const c = queue.shift();
          if (!c) break;
          const r = await geocodeOne(c);
          results.push(r);
        }
      }),
    );

    await persistBatch(results);

    processed += results.length;
    for (const r of results) {
      if (r.ok) {
        matched++;
        if (r.precision === 'housenumber') housenumber++;
        else if (r.precision === 'street') street++;
        else locality++;
      } else {
        nomatch++;
      }
    }

    const elapsed = (Date.now() - startTs) / 1000;
    const rate = processed / elapsed;
    const eta = (total - processed) / Math.max(rate, 1);
    console.log(
      `[GEO] ${processed}/${total} (${(processed / total * 100).toFixed(1)}%) · ` +
      `match ${matched} (${(matched / processed * 100).toFixed(0)}%) · ` +
      `house ${housenumber} street ${street} loc ${locality} nomatch ${nomatch} · ` +
      `${rate.toFixed(1)} q/s · ETA ${(eta / 60).toFixed(0)}min`,
    );
  }

  console.log(`[GEO] DONE. ${processed} processed in ${((Date.now() - startTs) / 60000).toFixed(1)} min.`);
  console.log(`[GEO] Match breakdown: housenumber=${housenumber} street=${street} locality=${locality} nomatch=${nomatch}`);
  await pool.end();
}

main().catch((err) => {
  console.error(err);
  process.exit(1);
});
