// @wuys/map · renderers/svg.js
//
// Primary renderer. Builds a stable DOM once (<svg> + <defs> + per-layer <g>),
// patches attributes per frame. No innerHTML in the hot path.
//
// Provides the renderer contract:
//   init/patch/destroy/hitTest/worldToScreen/screenToWorld/getViewport
// Plus layer-callbacks that compute per-country projection on demand:
//   pathForCountry(iso), projectedHeightOf(iso), viewToCountryRect(iso),
//   screenForLabel(iso, label), isVisibleAt(iso)

import * as EE    from "../math/projections/equal-earth.js";
import * as ORTHO from "../math/projections/orthographic.js";
import { splitOnAntimeridian } from "../math/antimeridian.js";
import { clipToHorizon } from "../math/horizon.js";
import { ringFromArcs } from "../data/topology.js";

import { registerRenderer } from "./base.js";

const SVG_NS = "http://www.w3.org/2000/svg";
const VIEWBOX = "0 0 2000 1000";
const DEG2RAD = Math.PI / 180;

/**
 * GLOBE HOT PATH — zero-allocation projection + horizon-clip + d-string build
 * for a single arc-referenced ring. Inlined so the JIT can keep everything in
 * registers; processes one ring in a single forward pass with no intermediate
 * arrays or {x,y,z} objects.
 *
 * Returns the SVG path d-fragment for the visible (front-hemisphere) portion.
 */
function projectRingGlobe(arcRefs, arcs, rotLambdaRad, sinPhi0, cosPhi0, baseR, CX, CY) {
  // Scratch state for the streaming clip.
  let d = "";
  let inSegment = false;
  // Previous point (math-space x/y/z + viewBox vx/vy).
  let prevX = 0, prevY = 0, prevZ = 0, prevVx = 0, prevVy = 0;
  // First point of the ring — needed to close the polygon edge from last → first.
  let firstX = 0, firstY = 0, firstZ = 0;
  let count = 0;

  for (let aRef = 0; aRef < arcRefs.length; aRef++) {
    const idx = arcRefs[aRef];
    const reverse = idx < 0;
    const arc = arcs[reverse ? ~idx : idx];
    const len = arc.length;
    const skip = count > 0 ? 1 : 0;          // skip duplicate seam on subsequent arcs
    const i0 = reverse ? len - 1 - skip : skip;
    const i1 = reverse ? -1 : len;
    const step = reverse ? -1 : 1;

    for (let i = i0; i !== i1; i += step) {
      const pt = arc[i];
      const lambda = pt[0] * DEG2RAD;
      const phi    = pt[1] * DEG2RAD;
      const dLam = lambda - rotLambdaRad;
      const cosLam = Math.cos(dLam);
      const sinLam = Math.sin(dLam);
      const cosPhi = Math.cos(phi);
      const sinPhi = Math.sin(phi);
      const x = cosPhi * sinLam;
      const y = cosPhi0 * sinPhi - sinPhi0 * cosPhi * cosLam;
      const z = sinPhi0 * sinPhi + cosPhi0 * cosPhi * cosLam;
      // Sub-pixel precision needed: the flag layer's per-fragment shader
      // computes lat/lon at exact sub-pixel positions, so the country path
      // must also be at exact (non-truncated) coords or the two render at
      // up-to-0.8-screen-pixel offset (flag "slides past" borders).
      const vx = CX + x * baseR;
      const vy = CY - y * baseR;

      if (count === 0) {
        firstX = x; firstY = y; firstZ = z;
        prevX = x; prevY = y; prevZ = z; prevVx = vx; prevVy = vy;
        count = 1;
        continue;
      }

      const v1 = prevZ >= 0;
      const v2 = z >= 0;
      if (v1 && v2) {
        if (!inSegment) { d += "M" + prevVx.toFixed(2) + "," + prevVy.toFixed(2); inSegment = true; }
        d += "L" + vx.toFixed(2) + "," + vy.toFixed(2);
      } else if (v1 && !v2) {
        const t = prevZ / (prevZ - z);
        let hx = prevX + t * (x - prevX);
        let hy = prevY + t * (y - prevY);
        const r = Math.sqrt(hx * hx + hy * hy) || 1;
        hx /= r; hy /= r;
        if (!inSegment) { d += "M" + prevVx.toFixed(2) + "," + prevVy.toFixed(2); inSegment = true; }
        d += "L" + (CX + hx * baseR).toFixed(2) + "," + (CY - hy * baseR).toFixed(2) + "Z";
        inSegment = false;
      } else if (!v1 && v2) {
        const t = prevZ / (prevZ - z);
        let hx = prevX + t * (x - prevX);
        let hy = prevY + t * (y - prevY);
        const r = Math.sqrt(hx * hx + hy * hy) || 1;
        hx /= r; hy /= r;
        d += "M" + (CX + hx * baseR).toFixed(2) + "," + (CY - hy * baseR).toFixed(2) +
             "L" + vx.toFixed(2) + "," + vy.toFixed(2);
        inSegment = true;
      }

      prevX = x; prevY = y; prevZ = z; prevVx = vx; prevVy = vy;
      count++;
    }
  }

  if (count > 2) {
    const firstVx = CX + firstX * baseR;
    const firstVy = CY - firstY * baseR;
    const v1 = prevZ >= 0;
    const v2 = firstZ >= 0;
    if (v1 && v2) {
      if (!inSegment) { d += "M" + prevVx.toFixed(2) + "," + prevVy.toFixed(2); inSegment = true; }
      d += "L" + firstVx.toFixed(2) + "," + firstVy.toFixed(2);
    } else if (v1 && !v2) {
      const t = prevZ / (prevZ - firstZ);
      let hx = prevX + t * (firstX - prevX);
      let hy = prevY + t * (firstY - prevY);
      const r = Math.sqrt(hx * hx + hy * hy) || 1;
      hx /= r; hy /= r;
      if (!inSegment) { d += "M" + prevVx.toFixed(2) + "," + prevVy.toFixed(2); inSegment = true; }
      d += "L" + (CX + hx * baseR).toFixed(2) + "," + (CY - hy * baseR).toFixed(2);
    } else if (!v1 && v2) {
      const t = prevZ / (prevZ - firstZ);
      let hx = prevX + t * (firstX - prevX);
      let hy = prevY + t * (firstY - prevY);
      const r = Math.sqrt(hx * hx + hy * hy) || 1;
      hx /= r; hy /= r;
      d += "M" + (CX + hx * baseR).toFixed(2) + "," + (CY - hy * baseR).toFixed(2) +
           "L" + firstVx.toFixed(2) + "," + firstVy.toFixed(2);
      inSegment = true;
    }
  }
  if (inSegment) d += "Z";
  return d;
}

export function createSvgRenderer({ state, events, theme, layers, getCountryName } = {}) {
  let svg = null;
  let target = null;
  let arcs = null;                            // decoded topojson arcs cache
  let geomByIso = new Map();                   // iso → geometry object
  const builtLayers = [];
  // Per-frame projection function + viewport mode
  let projectFn = null;

  // Flat-mode geometry caches — paths, label positions, country rects are
  // INVARIANT to pan/zoom in flat mode (the viewBox does the transform). Cache
  // them on first patch in flat mode so we don't redo EE projection on every
  // animation frame. Cleared whenever view mode changes.
  const _flatPathCache = new Map();       // iso → path d
  const _flatRectCache = new Map();       // iso → {x,y,w,h}
  const _flatLabelPos = new Map();        // iso+lon+lat → {x,y}
  let _flatPathReady = false;
  let _lastMode = null;
  let _resizeBound = false;

  function init(targetEl, opts) {
    target = targetEl;
    svg = document.createElementNS(SVG_NS, "svg");
    svg.setAttribute("viewBox", VIEWBOX);
    // `meet` preserves aspect with no cropping. We adapt the viewBox dimensions
    // each frame to match the container's aspect ratio (see doPatch flat branch),
    // so meet + matching aspect = perfect fill with NO cropping AND NO empty space.
    svg.setAttribute("preserveAspectRatio", "xMidYMid meet");
    svg.setAttribute("class", "wuys-map-svg");
    svg.style.cssText = "width:100%;height:100%;display:block;";
    target.appendChild(svg);

    // Build ocean backdrop (handled by theme — rect for flat, circle for globe)
    const oceanRect = document.createElementNS(SVG_NS, "rect");
    oceanRect.setAttribute("class", "wuys-map-ocean wuys-map-ocean-rect");
    oceanRect.setAttribute("x", "0"); oceanRect.setAttribute("y", "0");
    oceanRect.setAttribute("width", "2000"); oceanRect.setAttribute("height", "1000");
    svg.appendChild(oceanRect);
    const oceanCircle = document.createElementNS(SVG_NS, "circle");
    oceanCircle.setAttribute("class", "wuys-map-ocean wuys-map-ocean-circle");
    oceanCircle.setAttribute("cx", "1000"); oceanCircle.setAttribute("cy", "500");
    oceanCircle.setAttribute("r", "450");
    oceanCircle.setAttribute("display", "none");
    svg.appendChild(oceanCircle);

    // Build geometry cache from already-decoded topology
    arcs = state._topologyArcs;
    geomByIso = state._geomByIso;

    // Build layers
    const buildCtx = {
      svg, state, theme, events,
    };
    for (const layer of layers) {
      layer.build(buildCtx);
      builtLayers.push(layer);
    }

    applyTheme();

    // Re-patch on viewport resize so the aspect-adaptive viewBox keeps up.
    if (!_resizeBound) {
      _resizeBound = true;
      window.addEventListener("resize", () => { _flatPathReady = false; patch(); });
    }
  }

  function applyTheme() {
    if (!svg) return;
    // Apply CSS vars from theme
    for (const [k, v] of Object.entries(theme.cssVars || {})) {
      svg.style.setProperty(k, v);
    }
    // Ocean fills
    const oRect = svg.querySelector(".wuys-map-ocean-rect");
    const oCircle = svg.querySelector(".wuys-map-ocean-circle");
    if (oRect)   { oRect.setAttribute("fill", theme.ocean.fill); oRect.setAttribute("stroke", "none"); }
    if (oCircle) { oCircle.setAttribute("fill", theme.ocean.fill); oCircle.setAttribute("stroke", theme.ocean.stroke || "none"); oCircle.setAttribute("stroke-width", theme.ocean.strokeWidth || 0); }
    // Re-apply per-layer theme hooks
    for (const layer of builtLayers) if (layer.applyTheme) layer.applyTheme({ svg, state, theme });
    target.style.background = theme.background || "";
  }

  // === The per-frame patch ====================================================
  //
  // Synchronous patch — runs immediately when called. Earlier versions wrapped
  // this with requestAnimationFrame to coalesce multiple calls per frame, BUT
  // that added one frame of input latency (up to 16ms) before any visual could
  // appear. The new zero-allocation hot path is fast enough that we can afford
  // to run on every interaction event. Browsers coalesce mousemove to display
  // rate anyway, so we're not actually over-rendering.

  function patch() { doPatch(); }

  function doPatch() {
    if (!svg) return;
    // Self-heal: nav must be finite. If interaction or stale state wrote NaN,
    // snap back to defaults instead of writing NaN into the viewBox.
    if (!Number.isFinite(state.nav.lon))  { state.nav.lon  = 0; state.nav.tLon  = 0; }
    if (!Number.isFinite(state.nav.lat))  { state.nav.lat  = 0; state.nav.tLat  = 0; }
    if (!Number.isFinite(state.nav.zoom) || state.nav.zoom <= 0) {
      state.nav.zoom = 1; state.nav.tZoom = 1;
    }
    const mode = state.view.mode;
    // View mode flipped? Invalidate flat-mode geometry caches + force a full
    // layer repaint in the new mode.
    if (mode !== _lastMode) {
      _flatPathReady = false;
      _flatPathCache.clear();
      _flatRectCache.clear();
      _flatLabelPos.clear();
      _lastMode = mode;
    }
    // Toggle ocean shape based on view
    const oRect = svg.querySelector(".wuys-map-ocean-rect");
    const oCircle = svg.querySelector(".wuys-map-ocean-circle");
    if (mode === "globe") {
      oRect.setAttribute("display", "none");
      oCircle.removeAttribute("display");
      const baseR = 450 * state.nav.zoom;
      oCircle.setAttribute("r", baseR);
      svg.setAttribute("viewBox", VIEWBOX);
    } else {
      oRect.removeAttribute("display");
      oCircle.setAttribute("display", "none");
      // Flat-mode pan/zoom is purely a viewBox transform — cheap, browser-native.
      //
      // ASPECT-ADAPTIVE viewBox: Equal Earth's natural viewBox is 2000×1000 (2:1).
      // Containers rarely match that exactly — without adaptation we'd either crop
      // (slice) or get empty bars (meet). Instead we EXTEND the viewBox along the
      // dimension where the container is "extra", keeping the map content centred
      // in the natural 2000×1000 area and filling the surrounding strip with the
      // ocean rect. Result: map fills the screen exactly, with no cropping AND no
      // empty space at any aspect ratio.
      const rect = target.getBoundingClientRect();
      const aspect = (rect.width && rect.height) ? rect.width / rect.height : 2;
      let vbW, vbH, vbOffX, vbOffY;
      if (aspect > 2) {
        vbW = 1000 * aspect;
        vbH = 1000;
        vbOffX = (vbW - 2000) / 2;
        vbOffY = 0;
      } else {
        vbW = 2000;
        vbH = 2000 / aspect;
        vbOffX = 0;
        vbOffY = (vbH - 1000) / 2;
      }
      // Resize the ocean rect so it covers the extended viewBox (the strip
      // beyond the natural map area is just more ocean — visually seamless).
      oRect.setAttribute("x", String(-vbOffX));
      oRect.setAttribute("y", String(-vbOffY));
      oRect.setAttribute("width",  String(vbW));
      oRect.setAttribute("height", String(vbH));
      // Pan + zoom inside the extended viewBox. The user's nav.lon/lat still
      // projects through the natural EE box; we just shift the visible window.
      const w = vbW / state.nav.zoom;
      const h = vbH / state.nav.zoom;
      const [vbx, vby] = EE.project(state.nav.lon, state.nav.lat);
      // Clamp pan so the natural map (0..2000, 0..1000) stays in view.
      const cx = Math.max(w / 2 - vbOffX, Math.min(2000 + vbOffX - w / 2, vbx));
      const cy = Math.max(h / 2 - vbOffY, Math.min(1000 + vbOffY - h / 2, vby));
      svg.setAttribute("viewBox", (cx - w / 2) + " " + (cy - h / 2) + " " + w + " " + h);
    }

    // Layer patches are EXPENSIVE — each layer iterates every country and does
    // SVG attribute mutations. In flat mode the per-country geometry is
    // INVARIANT to pan/zoom (the viewBox does all the work), so we only need
    // to run the layer patches once per flat session. Subsequent animation
    // frames skip the entire iteration — that's the 60fps perf win.
    if (mode === "flat" && _flatPathReady) return;

    const ctx = buildPatchContext();
    for (const layer of builtLayers) layer.patch(ctx);
    if (mode === "flat") _flatPathReady = true;
  }

  // Invalidate the flat-mode cached patch when anything that affects the
  // per-country DOM changes (flag toggle hides/shows flag images, theme
  // change can flip showsLabels, etc.).
  function invalidateFlatPatch() { _flatPathReady = false; }
  if (events && events.on) {
    events.on("theme:change", invalidateFlatPatch);
    events.on("theme:flags",  invalidateFlatPatch);
    events.on("theme:labels", invalidateFlatPatch);
    events.on("view:change",  invalidateFlatPatch);
  }

  // === Patch context — the bridge between state and layers ====================

  function buildPatchContext() {
    const mode = state.view.mode;
    const rotLon = state.nav.lon, rotLat = state.nav.lat, zoom = state.nav.zoom;
    const sinRotPhi = Math.sin(rotLat * Math.PI / 180);
    const cosRotPhi = Math.cos(rotLat * Math.PI / 180);
    const rotLambdaRad = rotLon * Math.PI / 180;
    const orthoProject = ORTHO.projector(rotLambdaRad, rotLat * Math.PI / 180);
    const baseR = 450 * zoom;
    const CX = 1000, CY = 500;

    // Frame-local caches: countries-layer projects each country's ring points,
    // then flags-layer asks for the SAME path again for its clipPath. Without
    // this, globe-mode pays the projection cost twice (~half the per-frame JS
    // cost). Same for viewToCountryRect (countries + flags + labels all ask).
    // These maps live inside buildPatchContext so they're empty each frame.
    const _patchPathCache = new Map();
    const _patchRectCache = new Map();
    const _patchVisCache  = new Map();
    const _patchLabelCache = new Map();

    function isVisibleAt(iso) {
      if (mode !== "globe") return true;
      if (_patchVisCache.has(iso)) return _patchVisCache.get(iso);
      const cm = state.data.countries.get(iso);
      if (!cm) { _patchVisCache.set(iso, false); return false; }
      const phi = cm.lat * Math.PI / 180;
      const lam = cm.lon * Math.PI / 180;
      const cosDist = Math.sin(phi) * sinRotPhi + Math.cos(phi) * cosRotPhi * Math.cos(lam - rotLambdaRad);
      const v = cosDist >= -0.3;
      _patchVisCache.set(iso, v);
      return v;
    }

    function pathForCountry(iso) {
      // Flat-mode persistent cache: invariant to pan/zoom, valid until view change.
      if (mode === "flat" && _flatPathCache.has(iso)) return _flatPathCache.get(iso);
      // Globe-mode frame cache: shared between countries + flags layers.
      if (_patchPathCache.has(iso)) return _patchPathCache.get(iso);
      const geom = geomByIso.get(iso);
      if (!geom) return "";
      const polys = geom.type === "Polygon" ? [geom.arcs]
                 : geom.type === "MultiPolygon" ? geom.arcs : null;
      if (!polys) return "";
      let d = "";
      if (mode === "flat") {
        for (const poly of polys) {
          for (const ring of poly) {
            const pts = ringFromArcs(ring, arcs);
            if (pts.length < 3) continue;
            const proj = pts.map(([lon, lat]) => EE.project(lon, lat));
            d += splitOnAntimeridian(proj, 1000);
          }
        }
        _flatPathCache.set(iso, d);
      } else {
        // GLOBE HOT PATH — inlined to avoid object/array allocations per point.
        // Pre-frame the per-instance sin/cos so each vertex is just 4 trig calls
        // and a handful of muls + adds. Build the SVG d-string directly as we
        // go, using integer view coords (|0) to skip toFixed string churn.
        for (const poly of polys) {
          for (const ring of poly) {
            d += projectRingGlobe(ring, arcs, rotLambdaRad, sinRotPhi, cosRotPhi, baseR, CX, CY);
          }
        }
        _patchPathCache.set(iso, d);
      }
      return d;
    }

    function viewToCountryRect(iso) {
      if (mode === "flat" && _flatRectCache.has(iso)) return _flatRectCache.get(iso);
      if (_patchRectCache.has(iso)) return _patchRectCache.get(iso);
      const cm = state.data.countries.get(iso);
      if (!cm) return null;
      // Flag STAMP placement (Hoi4 / Stellaris / Civ style — the standard for
      // premium political maps):
      //   • Centered at the country's pole of inaccessibility (deepest interior
      //     point of the main sub-polygon, computed once via Mapbox polylabel).
      //   • 3:2 aspect (universal flag aspect — looks right for every shape).
      //   • Height = inscribed-circle radius × 2 × scale, clamped to [MIN, MAX].
      //     Small countries get a readable floor; large countries are capped so
      //     the flag doesn't dominate the map.
      //   • `preserveAspectRatio="meet"` in flags.js shows the flag at natural
      //     aspect; the clipPath is a safety guard for irregular shapes.
      const aLon = cm.flagAnchorLon ?? cm.lon;
      const aLat = cm.flagAnchorLat ?? cm.lat;
      const radiusDeg = cm.flagAnchorRadius || 0.5;
      const ASPECT = 1.5; // 3:2 flag
      if (mode === "flat") {
        // 1° latitude ≈ 5.555 viewBox units (matches projectedHeightOf).
        let h = radiusDeg * 2 * 5.555 * 0.95;
        const MIN_H = 18, MAX_H = 80;
        if (h < MIN_H) h = MIN_H;
        if (h > MAX_H) h = MAX_H;
        const w = h * ASPECT;
        const [cx, cy] = EE.project(aLon, aLat);
        const rect = { x: cx - w / 2, y: cy - h / 2, w, h };
        _flatRectCache.set(iso, rect);
        return rect;
      } else {
        const pc = orthoProject(aLon, aLat);
        if (pc.z < -0.05) { _patchRectCache.set(iso, null); return null; }
        // Globe: 1° ≈ baseR/57.3 viewBox units at the great circle.
        const unit = baseR / 57.3;
        let h = radiusDeg * 2 * unit * 0.95;
        const MIN_H = 10, MAX_H = 60;
        if (h < MIN_H) h = MIN_H;
        if (h > MAX_H) h = MAX_H;
        const w = h * ASPECT;
        const cx = CX + pc.x * baseR;
        const cy = CY - pc.y * baseR;
        const rect = { x: cx - w / 2, y: cy - h / 2, w, h };
        _patchRectCache.set(iso, rect);
        return rect;
      }
    }

    function projectedHeightOf(iso) {
      const cm = state.data.countries.get(iso);
      if (!cm) return 0;
      if (mode === "flat") return cm.latRange * 5.555;       // viewBox units per degree at equator
      return cm.latRange * baseR / 57.3;
    }

    function screenForLabel(iso, label) {
      const cm = state.data.countries.get(iso);
      if (!cm) return null;
      const lon = label && label.lon != null ? label.lon : cm.lon;
      const lat = label && label.lat != null ? label.lat : cm.lat;
      if (mode === "flat") {
        const key = iso + ":" + lon + "," + lat;
        const cached = _flatLabelPos.get(key);
        if (cached) return cached;
        const [x, y] = EE.project(lon, lat);
        const pos = { x, y };
        _flatLabelPos.set(key, pos);
        return pos;
      }
      const p = orthoProject(lon, lat);
      if (p.z < 0) return null;
      return { x: CX + p.x * baseR, y: CY - p.y * baseR };
    }

    function projectRing(points) {
      if (!points || points.length < 3) return "";
      if (mode === "flat") {
        const proj = points.map(([lon, lat]) => EE.project(lon, lat));
        return splitOnAntimeridian(proj, 1000);
      }
      const projected = points.map(([lon, lat]) => orthoProject(lon, lat));
      return clipToHorizon(projected, (p) => [CX + p.x * baseR, CY - p.y * baseR]);
    }

    function viewToPolyRect(poly) {
      // Same geometry as viewToCountryRect but driven by a specific polygon's
      // anchor + inscribed radius. Used by the flag layer to stamp every
      // significant island/exclave separately.
      const aLon = poly.anchorLon;
      const aLat = poly.anchorLat;
      const radiusDeg = poly.anchorRadius || 0.3;
      const ASPECT = 1.5;
      if (mode === "flat") {
        let h = radiusDeg * 2 * 5.555 * 0.95;
        const MIN_H = 12, MAX_H = 80;
        if (h < MIN_H) h = MIN_H;
        if (h > MAX_H) h = MAX_H;
        const w = h * ASPECT;
        const [cx, cy] = EE.project(aLon, aLat);
        return { x: cx - w / 2, y: cy - h / 2, w, h };
      } else {
        const pc = orthoProject(aLon, aLat);
        if (pc.z < -0.05) return null;
        const unit = baseR / 57.3;
        let h = radiusDeg * 2 * unit * 0.95;
        const MIN_H = 7, MAX_H = 60;
        if (h < MIN_H) h = MIN_H;
        if (h > MAX_H) h = MAX_H;
        const w = h * ASPECT;
        const cx = CX + pc.x * baseR;
        const cy = CY - pc.y * baseR;
        return { x: cx - w / 2, y: cy - h / 2, w, h };
      }
    }

    function pathForPoly(poly) {
      // Project a single polygon's ring points to the current viewport.
      // Used by the flag layer to build per-polygon clip paths (Indonesia
      // has ~12 polys, each gets its own image+clip so ortho distortion
      // stays small inside each one).
      if (!poly || !poly.points) return "";
      return projectRing(poly.points);
    }

    function viewToPolyAffine(poly) {
      // Returns the 2D affine matrix [a, b, c, d, e, f] that maps the unit
      // square (0,0)-(1,1) to the polygon's lat/lon bbox projected to the
      // current viewport. Maps:
      //   (0,0) → top-left  (lonMin, latMax)
      //   (1,0) → top-right (lonMax, latMax)
      //   (0,1) → bottom-left (lonMin, latMin)
      // The fourth corner (1,1) is implicit (parallelogram).
      // Captures translation + rotation + skew + scale → flag follows the
      // polygon's projected orientation as the globe rotates.
      if (!poly) return null;
      if (mode === "flat") {
        const tl = EE.project(poly.lonMin, poly.latMax);
        const tr = EE.project(poly.lonMax, poly.latMax);
        const bl = EE.project(poly.lonMin, poly.latMin);
        if (!Number.isFinite(tl[0]) || !Number.isFinite(tr[0]) || !Number.isFinite(bl[0])) return null;
        return [tr[0] - tl[0], tr[1] - tl[1], bl[0] - tl[0], bl[1] - tl[1], tl[0], tl[1]];
      } else {
        const tlP = orthoProject(poly.lonMin, poly.latMax);
        const trP = orthoProject(poly.lonMax, poly.latMax);
        const blP = orthoProject(poly.lonMin, poly.latMin);
        if (tlP.z < -0.1 && trP.z < -0.1 && blP.z < -0.1) return null;
        const tlx = CX + tlP.x * baseR, tly = CY - tlP.y * baseR;
        const trx = CX + trP.x * baseR, tryv = CY - trP.y * baseR;
        const blx = CX + blP.x * baseR, bly = CY - blP.y * baseR;
        if (!Number.isFinite(tlx) || !Number.isFinite(trx) || !Number.isFinite(blx)) return null;
        return [trx - tlx, tryv - tly, blx - tlx, bly - tly, tlx, tly];
      }
    }

    function viewToPolyFullRect(poly) {
      // Bounding box of the polygon's PROJECTED vertices in screen units.
      // GLOBE: walk every vertex through orthoProject, take the min/max — the
      // bbox follows the polygon's actual on-screen shape (compressing at the
      // visible-hemisphere rim along with the polygon). The flag image, when
      // stretched into this bbox with preserveAspectRatio="none", follows the
      // polygon's projected geometry directly → no flag drift under rotation.
      // FLAT: bbox is invariant; use the lat/lon corners projected via EE.
      if (!poly || !poly.points) return null;
      if (mode === "flat") {
        const ul = EE.project(poly.lonMin, poly.latMax);
        const lr = EE.project(poly.lonMax, poly.latMin);
        const w = lr[0] - ul[0], h = lr[1] - ul[1];
        if (!Number.isFinite(w) || !Number.isFinite(h) || w <= 0 || h <= 0) return null;
        return { x: ul[0], y: ul[1], w, h };
      } else {
        let minVx = Infinity, maxVx = -Infinity;
        let minVy = Infinity, maxVy = -Infinity;
        let any = false;
        const pts = poly.points;
        for (let i = 0; i < pts.length; i++) {
          const lon = pts[i][0], lat = pts[i][1];
          const p = orthoProject(lon, lat);
          if (p.z < -0.05) continue;
          any = true;
          const sx = CX + p.x * baseR;
          const sy = CY - p.y * baseR;
          if (sx < minVx) minVx = sx;
          if (sx > maxVx) maxVx = sx;
          if (sy < minVy) minVy = sy;
          if (sy > maxVy) maxVy = sy;
        }
        if (!any) return null;
        const w = maxVx - minVx, h = maxVy - minVy;
        if (!Number.isFinite(w) || !Number.isFinite(h) || w <= 0 || h <= 0) return null;
        return { x: minVx, y: minVy, w, h };
      }
    }

    function viewToFullCountryRect(iso) {
      // Rect covering the country's full geographic extent in viewport units.
      // FLAT mode: project the bbox corners directly (Equal Earth is stable).
      // GLOBE mode: anchor to the country's geographic centroid + geographic
      // span × pixels-per-degree-at-centroid. Doesn't reshape with orthographic
      // edge distortion → flag stays put inside the country as you rotate.
      const cm = state.data.countries.get(iso);
      if (!cm) return null;
      if (mode === "flat") {
        const minLon = cm.lonMin, maxLon = cm.lonMax;
        const minLat = cm.latMin, maxLat = cm.latMax;
        const ul = EE.project(minLon, maxLat);
        const lr = EE.project(maxLon, minLat);
        const w = lr[0] - ul[0], h = lr[1] - ul[1];
        if (!Number.isFinite(w) || !Number.isFinite(h) || w <= 0 || h <= 0) return null;
        return { x: ul[0], y: ul[1], w, h };
      } else {
        const lon = cm.lon, lat = cm.lat;
        const pc = orthoProject(lon, lat);
        if (pc.z < -0.05) return null;
        const cosLat = Math.cos(lat * Math.PI / 180) || 1;
        const unit = baseR / 57.3;
        const w = cm.lonRange * cosLat * unit;
        const h = cm.latRange * unit;
        if (!Number.isFinite(w) || !Number.isFinite(h) || w <= 0 || h <= 0) return null;
        const cx = CX + pc.x * baseR;
        const cy = CY - pc.y * baseR;
        return { x: cx - w / 2, y: cy - h / 2, w, h };
      }
    }

    function projectLatLonToScreen(lon, lat) {
      // (lon, lat) → {x, y} in screen viewBox coords, or null when the point
      // is on the back hemisphere (globe). Used by per-triangle flag rendering
      // so the affine for each triangle is built from real projected vertices.
      if (mode === "flat") {
        const [x, y] = EE.project(lon, lat);
        if (!Number.isFinite(x) || !Number.isFinite(y)) return null;
        return { x, y };
      } else {
        const p = orthoProject(lon, lat);
        if (p.z < -0.05) return null;
        return { x: CX + p.x * baseR, y: CY - p.y * baseR };
      }
    }

    function viewToPointRect(lon, lat, sizeHint) {
      // Place a 3:2 flag stamp at an explicit lon/lat (hand-curated label
      // point). sizeHint is a per-point scale multiplier (1.0 default;
      // lower-ranked points get smaller so primary > secondary > tertiary).
      const ASPECT = 1.5;
      const scale = sizeHint != null ? sizeHint : 1.0;
      if (mode === "flat") {
        const h = 28 * scale;       // viewBox units. 28 ≈ 5° lat — reads at world view.
        const w = h * ASPECT;
        const [cx, cy] = EE.project(lon, lat);
        if (!Number.isFinite(cx) || !Number.isFinite(cy)) return null;
        return { x: cx - w / 2, y: cy - h / 2, w, h };
      } else {
        const pc = orthoProject(lon, lat);
        if (pc.z < -0.05) return null;
        const unit = baseR / 57.3;
        const h = 5 * scale * unit;    // 5° at current zoom
        const w = h * ASPECT;
        const cx = CX + pc.x * baseR;
        const cy = CY - pc.y * baseR;
        return { x: cx - w / 2, y: cy - h / 2, w, h };
      }
    }

    return {
      state, theme, mode,
      pathForCountry, pathForPoly,
      viewToCountryRect, viewToPolyRect, viewToPointRect,
      viewToFullCountryRect, viewToPolyFullRect, viewToPolyAffine,
      projectLatLonToScreen,
      projectedHeightOf, screenForLabel, isVisibleAt,
      projectRing,
    };
  }

  // === Coordinate helpers (renderer contract) =================================

  function worldToScreen({ lon, lat }) {
    const mode = state.view.mode;
    const rect = svg.getBoundingClientRect();
    const vb = svg.viewBox.baseVal;
    let vbx, vby;
    if (mode === "flat") {
      [vbx, vby] = EE.project(lon, lat);
    } else {
      const p = ORTHO.project(lon, lat, { rotLon: state.nav.lon, rotLat: state.nav.lat, zoom: state.nav.zoom });
      vbx = p.vbx; vby = p.vby;
    }
    // Map viewBox → screen accounting for preserveAspectRatio="xMidYMid meet"
    const scale = Math.min(rect.width / vb.width, rect.height / vb.height);
    const renderedW = vb.width * scale, renderedH = vb.height * scale;
    const ox = (rect.width - renderedW) / 2, oy = (rect.height - renderedH) / 2;
    return { x: ox + (vbx - vb.x) * scale, y: oy + (vby - vb.y) * scale };
  }

  function screenToWorld({ x, y }) {
    const mode = state.view.mode;
    const rect = svg.getBoundingClientRect();
    const vb = svg.viewBox.baseVal;
    const scale = Math.min(rect.width / vb.width, rect.height / vb.height);
    const renderedW = vb.width * scale, renderedH = vb.height * scale;
    const ox = (rect.width - renderedW) / 2, oy = (rect.height - renderedH) / 2;
    const vbx = vb.x + (x - ox) / scale;
    const vby = vb.y + (y - oy) / scale;
    if (mode === "flat") return EE.unproject(vbx, vby);
    return ORTHO.unproject(vbx, vby, { rotLon: state.nav.lon, rotLat: state.nav.lat, zoom: state.nav.zoom });
  }

  function viewportToWorld(dxPx, dyPx) {
    // Given a pixel delta (drag), return the equivalent lon/lat delta at the current view.
    const c = worldToScreen({ lon: state.nav.lon, lat: state.nav.lat });
    const target = screenToWorld({ x: c.x + dxPx, y: c.y + dyPx });
    return { dLon: target.lon - state.nav.lon, dLat: target.lat - state.nav.lat };
  }

  function getViewport() {
    // Returns lon/lat bounds of what's currently visible.
    // For globe this is approximate (the visible disc is not axis-aligned).
    const mode = state.view.mode;
    if (mode === "flat") {
      const vb = svg.viewBox.baseVal;
      const ul = EE.unproject(vb.x, vb.y);
      const lr = EE.unproject(vb.x + vb.width, vb.y + vb.height);
      return { lonMin: ul.lon, lonMax: lr.lon, latMin: lr.lat, latMax: ul.lat };
    }
    return { lonMin: state.nav.lon - 90, lonMax: state.nav.lon + 90, latMin: state.nav.lat - 90, latMax: state.nav.lat + 90 };
  }

  function hitTest(x, y) {
    if (!svg) return null;
    const el = document.elementFromPoint(x, y);
    if (!el) return null;
    const path = el.closest("path[data-iso]");
    if (!path) return null;
    return { iso: path.getAttribute("data-iso"), element: path };
  }

  function destroy() {
    for (const layer of builtLayers) layer.destroy && layer.destroy();
    builtLayers.length = 0;
    if (svg && svg.parentNode) svg.parentNode.removeChild(svg);
    svg = null;
  }

  const renderer = {
    init: (...a) => Promise.resolve(init(...a)),
    patch,
    destroy,
    hitTest,
    worldToScreen,
    screenToWorld,
    viewportToWorld,
    getViewport,
    applyTheme,
    getSvg: () => svg,
  };
  return renderer;
}

registerRenderer("svg", (args) => createSvgRenderer(args));
