import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { DEFAULT_LAYER_STYLE, type GeoLibreLayer } from "@geolibre/core";
import {
  calculateBoundsAlgorithm,
  countFeaturesAlgorithm,
  getAlgorithm,
  getVectorTool,
} from "@geolibre/processing";
import type { FeatureCollection } from "geojson";

const layer: GeoLibreLayer = {
  id: "layer-a",
  name: "Layer A",
  type: "geojson",
  source: { type: "geojson" },
  visible: true,
  opacity: 1,
  style: { ...DEFAULT_LAYER_STYLE },
  metadata: {},
  geojson: {
    type: "FeatureCollection",
    features: [
      {
        type: "Feature",
        properties: { name: "A" },
        geometry: { type: "Point", coordinates: [-78, 35] },
      },
      {
        type: "Feature",
        properties: { name: "B" },
        geometry: { type: "Point", coordinates: [-77, 36] },
      },
    ],
  },
};

describe("processing registry", () => {
  it("finds registered algorithms by id", () => {
    assert.equal(getAlgorithm("count-features"), countFeaturesAlgorithm);
    assert.equal(getAlgorithm("missing"), undefined);
  });

  it("counts GeoJSON features", () => {
    const messages: string[] = [];
    countFeaturesAlgorithm.run({
      layers: [layer],
      parameters: { layer: "layer-a" },
      log: (message) => messages.push(message),
    });

    assert.deepEqual(messages, ["Feature count: 2"]);
  });

  it("spatially joins zone attributes onto points", () => {
    const zone: GeoLibreLayer = {
      ...layer,
      id: "zone",
      name: "Zone",
      geojson: {
        type: "FeatureCollection",
        features: [
          {
            type: "Feature",
            properties: { region: "north" },
            geometry: {
              type: "Polygon",
              coordinates: [
                [
                  [0, 0],
                  [0, 10],
                  [10, 10],
                  [10, 0],
                  [0, 0],
                ],
              ],
            },
          },
        ],
      },
    };
    const points: GeoLibreLayer = {
      ...layer,
      id: "points",
      name: "Points",
      geojson: {
        type: "FeatureCollection",
        features: [
          {
            type: "Feature",
            properties: { name: "inside" },
            geometry: { type: "Point", coordinates: [5, 5] },
          },
          {
            type: "Feature",
            properties: { name: "outside" },
            geometry: { type: "Point", coordinates: [20, 20] },
          },
        ],
      },
    };

    const tool = getVectorTool("spatial-join");
    assert.ok(tool);

    // Inner join keeps only the point that falls inside the zone.
    let inner: FeatureCollection | null = null;
    tool.run({
      layers: [zone, points],
      parameters: { layer: "points", overlay: "zone", how: "inner" },
      log: () => {},
      addResultLayer: (_name, geojson) => {
        inner = geojson;
      },
    });
    assert.equal(inner!.features.length, 1);
    assert.equal(inner!.features[0].properties?.name, "inside");
    assert.equal(inner!.features[0].properties?.region, "north");

    // Left join keeps both points; the outside one gets no zone attribute.
    let left: FeatureCollection | null = null;
    tool.run({
      layers: [zone, points],
      parameters: { layer: "points", overlay: "zone", how: "left" },
      log: () => {},
      addResultLayer: (_name, geojson) => {
        left = geojson;
      },
    });
    assert.equal(left!.features.length, 2);
    const outside = left!.features.find((f) => f.properties?.name === "outside");
    // Unmatched left-join rows null-fill the join columns (consistent schema,
    // mirrors the sidecar), so `region` is present and null rather than absent.
    assert.equal(outside?.properties?.region, null);
  });

  it("spatial join drops feature ids, validates inputs, and handles empty join layers", () => {
    const tool = getVectorTool("spatial-join");
    assert.ok(tool);

    // Two overlapping zones so a single input point matches both (one-to-many).
    const zoneFeature = (region: string) => ({
      type: "Feature" as const,
      properties: { region },
      geometry: {
        type: "Polygon" as const,
        coordinates: [
          [
            [0, 0],
            [0, 10],
            [10, 10],
            [10, 0],
            [0, 0],
          ],
        ],
      },
    });
    const zones: GeoLibreLayer = {
      ...layer,
      id: "zones",
      name: "Zones",
      geojson: {
        type: "FeatureCollection",
        features: [zoneFeature("north"), zoneFeature("south")],
      },
    };
    // Input point carries an `id`; a one-to-many join must not duplicate it.
    const pts: GeoLibreLayer = {
      ...layer,
      id: "pts",
      name: "Pts",
      geojson: {
        type: "FeatureCollection",
        features: [
          {
            type: "Feature",
            id: "p1",
            properties: { name: "pt" },
            geometry: { type: "Point", coordinates: [5, 5] },
          },
        ],
      },
    };

    let res: FeatureCollection | null = null;
    tool.run({
      layers: [zones, pts],
      parameters: { layer: "pts", overlay: "zones", how: "inner" },
      log: () => {},
      addResultLayer: (_n, g) => {
        res = g;
      },
    });
    assert.equal(res!.features.length, 2);
    assert.ok(res!.features.every((f) => f.id === undefined));
    // The one input feature matching two zones yields one output per match,
    // each carrying that join feature's distinct attribute.
    const regions = res!.features.map((f) => f.properties?.region).sort();
    assert.deepEqual(regions, ["north", "south"]);

    // Empty join layer: left keeps the input, inner returns nothing.
    const emptyJoin: GeoLibreLayer = {
      ...layer,
      id: "empty",
      name: "Empty",
      geojson: { type: "FeatureCollection", features: [] },
    };
    let leftEmpty: FeatureCollection | null = null;
    tool.run({
      layers: [pts, emptyJoin],
      parameters: { layer: "pts", overlay: "empty", how: "left" },
      log: () => {},
      addResultLayer: (_n, g) => {
        leftEmpty = g;
      },
    });
    assert.equal(leftEmpty!.features.length, 1);

    let innerEmpty: FeatureCollection | null = null;
    tool.run({
      layers: [pts, emptyJoin],
      parameters: { layer: "pts", overlay: "empty", how: "inner" },
      log: () => {},
      addResultLayer: (_n, g) => {
        innerEmpty = g;
      },
    });
    assert.equal(innerEmpty!.features.length, 0);

    // Unknown predicate is rejected (no result layer), mirroring the backend.
    let produced = false;
    const logs: string[] = [];
    tool.run({
      layers: [zones, pts],
      parameters: { layer: "pts", overlay: "zones", predicate: "bogus" },
      log: (m) => logs.push(m),
      addResultLayer: () => {
        produced = true;
      },
    });
    assert.equal(produced, false);
    assert.ok(logs.some((m) => m.includes("unknown predicate")));
  });

  it("attribute-joins a table's fields onto features by a key", () => {
    const tool = getVectorTool("attribute-join");
    assert.ok(tool);

    // Counties keyed by GEOID (a string with a leading zero) plus one county
    // whose key is stored as a number, to exercise the string/number coercion.
    const counties: GeoLibreLayer = {
      ...layer,
      id: "counties",
      name: "Counties",
      geojson: {
        type: "FeatureCollection",
        features: [
          {
            type: "Feature",
            properties: { GEOID: "01001" },
            geometry: { type: "Point", coordinates: [0, 0] },
          },
          {
            type: "Feature",
            properties: { GEOID: 1003 },
            geometry: { type: "Point", coordinates: [1, 1] },
          },
          {
            type: "Feature",
            properties: { GEOID: "09999" },
            geometry: { type: "Point", coordinates: [2, 2] },
          },
        ],
      },
    };
    // Stats table (geometry ignored). Two rows share key "1003"; the first wins.
    const stats: GeoLibreLayer = {
      ...layer,
      id: "stats",
      name: "Stats",
      geojson: {
        type: "FeatureCollection",
        features: [
          {
            type: "Feature",
            properties: { code: "01001", pop: 100, label: "Autauga" },
            geometry: { type: "Point", coordinates: [0, 0] },
          },
          {
            // Numeric county key matched against the string "1003" via valueToString.
            type: "Feature",
            properties: { code: "1003", pop: 200, label: "Barbour" },
            geometry: { type: "Point", coordinates: [0, 0] },
          },
          {
            type: "Feature",
            properties: { code: "1003", pop: 999, label: "DUPLICATE" },
            geometry: { type: "Point", coordinates: [0, 0] },
          },
        ],
      },
    };

    // Left join keeps every county; "09999" has no stats row so its brought-over
    // columns are null-filled.
    let left: FeatureCollection | null = null;
    tool.run({
      layers: [counties, stats],
      parameters: {
        layer: "counties",
        overlay: "stats",
        target_field: "GEOID",
        join_field: "code",
        how: "left",
      },
      log: () => {},
      addResultLayer: (_name, geojson) => {
        left = geojson;
      },
    });
    assert.equal(left!.features.length, 3);
    const autauga = left!.features.find((f) => f.properties?.GEOID === "01001");
    assert.equal(autauga?.properties?.pop, 100);
    assert.equal(autauga?.properties?.label, "Autauga");
    // Numeric key 1003 matches the string "1003"; the first duplicate row wins.
    const barbour = left!.features.find((f) => f.properties?.GEOID === 1003);
    assert.equal(barbour?.properties?.pop, 200);
    assert.equal(barbour?.properties?.label, "Barbour");
    // The default field set excludes the join key ("code"), so it is not copied.
    assert.equal("code" in (barbour?.properties ?? {}), false);
    // Unmatched row null-fills the brought-over columns (consistent schema).
    const unmatched = left!.features.find((f) => f.properties?.GEOID === "09999");
    assert.equal(unmatched?.properties?.pop, null);
    assert.equal(unmatched?.properties?.label, null);

    // Inner join drops the unmatched county and honours an explicit field list.
    let inner: FeatureCollection | null = null;
    tool.run({
      layers: [counties, stats],
      parameters: {
        layer: "counties",
        overlay: "stats",
        target_field: "GEOID",
        join_field: "code",
        how: "inner",
        fields: "pop",
      },
      log: () => {},
      addResultLayer: (_name, geojson) => {
        inner = geojson;
      },
    });
    assert.equal(inner!.features.length, 2);
    const innerBarbour = inner!.features.find((f) => f.properties?.GEOID === 1003);
    assert.equal(innerBarbour?.properties?.pop, 200);
    // Only "pop" was requested, so "label" is not brought over.
    assert.equal("label" in (innerBarbour?.properties ?? {}), false);

    // A fields string that is only separators (e.g. ",") is treated as blank,
    // falling back to the default (all join fields except the key) rather than
    // erroring.
    let blankFields: FeatureCollection | null = null;
    tool.run({
      layers: [counties, stats],
      parameters: {
        layer: "counties",
        overlay: "stats",
        target_field: "GEOID",
        join_field: "code",
        how: "left",
        fields: " , ",
      },
      log: () => {},
      addResultLayer: (_name, geojson) => {
        blankFields = geojson;
      },
    });
    const blankAutauga = blankFields!.features.find((f) => f.properties?.GEOID === "01001");
    assert.equal(blankAutauga?.properties?.pop, 100);
    assert.equal(blankAutauga?.properties?.label, "Autauga");
  });

  it("selects features by attribute value", () => {
    const tool = getVectorTool("select-by-value");
    assert.ok(tool);
    const attr: GeoLibreLayer = {
      ...layer,
      id: "attr",
      name: "Attr",
      geojson: {
        type: "FeatureCollection",
        features: [
          {
            type: "Feature",
            properties: { name: "alpha", pop: 10 },
            geometry: { type: "Point", coordinates: [0, 0] },
          },
          {
            type: "Feature",
            properties: { name: "beta", pop: 20 },
            geometry: { type: "Point", coordinates: [1, 0] },
          },
          {
            type: "Feature",
            properties: { name: "gamma", pop: null },
            geometry: { type: "Point", coordinates: [2, 0] },
          },
          {
            type: "Feature",
            properties: { name: "delta" }, // no "pop" key at all
            geometry: { type: "Point", coordinates: [3, 0] },
          },
        ],
      },
    };
    const run = (parameters: Record<string, unknown>): FeatureCollection => {
      let out: FeatureCollection = { type: "FeatureCollection", features: [] };
      tool.run({
        layers: [attr],
        parameters: { layer: "attr", ...parameters },
        log: () => {},
        addResultLayer: (_n, g) => {
          out = g;
        },
      });
      return out;
    };
    const names = (fc: FeatureCollection): (string | undefined)[] =>
      fc.features.map((f) => f.properties?.name as string | undefined).sort();

    // Numeric comparison: pop > 15 → only beta.
    assert.deepEqual(names(run({ field: "pop", operator: "gt", value: "15" })), ["beta"]);
    // String equals.
    assert.deepEqual(names(run({ field: "name", operator: "eq", value: "alpha" })), ["alpha"]);
    // Case-insensitive contains.
    assert.deepEqual(names(run({ field: "name", operator: "contains", value: "ET" })), ["beta"]);
    // is-null matches both an explicit null (gamma) and a missing key (delta).
    assert.deepEqual(names(run({ field: "pop", operator: "is-null" })), ["delta", "gamma"]);
    // is-not-null is the inverse: only the features with a real pop value.
    assert.deepEqual(names(run({ field: "pop", operator: "is-not-null" })), ["alpha", "beta"]);
    // starts-with is case-insensitive.
    assert.deepEqual(names(run({ field: "name", operator: "starts-with", value: "AL" })), [
      "alpha",
    ]);
    // neq excludes the matched value (nulls/missing never compare equal).
    assert.deepEqual(names(run({ field: "name", operator: "neq", value: "alpha" })), [
      "beta",
      "delta",
      "gamma",
    ]);
    // SQL-like: neq on a numeric field excludes the null (gamma) and missing
    // (delta) rows, not just the equal one.
    assert.deepEqual(names(run({ field: "pop", operator: "neq", value: "10" })), ["beta"]);
    // gte / lte boundary checks.
    assert.deepEqual(names(run({ field: "pop", operator: "gte", value: "20" })), ["beta"]);
    assert.deepEqual(names(run({ field: "pop", operator: "lte", value: "10" })), ["alpha"]);
    // A field absent from every feature is schemaless all-empty, not an error:
    // eq matches nothing, is-null matches every feature.
    assert.equal(run({ field: "missing", operator: "eq", value: "x" }).features.length, 0);
    assert.equal(run({ field: "missing", operator: "is-null" }).features.length, 4);

    // A hex-looking string compares as text, not coerced to a number — matching
    // Python's float(), which rejects "0x10" (so the engines stay in sync).
    const hexLayer: GeoLibreLayer = {
      ...layer,
      id: "hex",
      name: "Hex",
      geojson: {
        type: "FeatureCollection",
        features: [
          {
            type: "Feature",
            properties: { code: "0x10" },
            geometry: { type: "Point", coordinates: [0, 0] },
          },
        ],
      },
    };
    const runHex = (parameters: Record<string, unknown>): number => {
      let n = 0;
      tool.run({
        layers: [hexLayer],
        parameters: { layer: "hex", field: "code", ...parameters },
        log: () => {},
        addResultLayer: (_n, g) => {
          n = g.features.length;
        },
      });
      return n;
    };
    assert.equal(runHex({ operator: "eq", value: "16" }), 0);
    assert.equal(runHex({ operator: "eq", value: "0x10" }), 1);
  });

  it("extracts a random subset of features", () => {
    const tool = getVectorTool("random-extract");
    assert.ok(tool);
    // 10 features with distinct, ordered ids so we can check membership + order.
    const many: GeoLibreLayer = {
      ...layer,
      id: "many",
      name: "Many",
      geojson: {
        type: "FeatureCollection",
        features: Array.from({ length: 10 }, (_, i) => ({
          type: "Feature" as const,
          properties: { idx: i },
          geometry: { type: "Point" as const, coordinates: [i, 0] },
        })),
      },
    };
    const run = (parameters: Record<string, unknown>): FeatureCollection => {
      let out: FeatureCollection = { type: "FeatureCollection", features: [] };
      tool.run({
        layers: [many],
        parameters: { layer: "many", ...parameters },
        log: () => {},
        addResultLayer: (_n, g) => {
          out = g;
        },
      });
      return out;
    };

    // By count: exactly N features, all drawn from the input, no duplicates,
    // and the input's feature order is preserved in the output.
    const byCount = run({ method: "count", count: 3 });
    assert.equal(byCount.features.length, 3);
    const idxs = byCount.features.map((f) => f.properties?.idx as number);
    assert.ok(idxs.every((i) => i >= 0 && i < 10));
    assert.equal(new Set(idxs).size, 3);
    assert.deepEqual(
      idxs,
      [...idxs].sort((a, b) => a - b),
    );

    // By percentage: 40% of 10 → 4 features.
    assert.equal(run({ method: "percentage", percentage: 40 }).features.length, 4);
    // 0% (and a count of 0) select nothing.
    assert.equal(run({ method: "percentage", percentage: 0 }).features.length, 0);
    assert.equal(run({ method: "count", count: 0 }).features.length, 0);
    // A count larger than the input is clamped to every feature, not an error.
    assert.equal(run({ method: "count", count: 999 }).features.length, 10);
    // Likewise a percentage above 100 clamps to the full input.
    assert.equal(run({ method: "percentage", percentage: 150 }).features.length, 10);
  });

  it("selects features by location, including disjoint", () => {
    const tool = getVectorTool("select-by-location");
    assert.ok(tool);
    const square = (id: string, x: number): GeoLibreLayer => ({
      ...layer,
      id,
      name: id,
      geojson: {
        type: "FeatureCollection",
        features: [
          {
            type: "Feature",
            properties: { id },
            geometry: {
              type: "Polygon",
              coordinates: [
                [
                  [x, 0],
                  [x, 1],
                  [x + 1, 1],
                  [x + 1, 0],
                  [x, 0],
                ],
              ],
            },
          },
        ],
      },
    });
    const a = square("a", 0); // covers x in [0,1]
    const overlap = square("overlap", 0.5); // intersects a
    const far = square("far", 10); // disjoint from a
    // A large square that fully contains `a`, and a tiny one fully inside it.
    const bigPoly = (id: string, coords: number[][]): GeoLibreLayer => ({
      ...layer,
      id,
      name: id,
      geojson: {
        type: "FeatureCollection",
        features: [
          {
            type: "Feature",
            properties: { id },
            geometry: { type: "Polygon", coordinates: [coords] },
          },
        ],
      },
    });
    const big = bigPoly("big", [
      [-5, -5],
      [-5, 5],
      [5, 5],
      [5, -5],
      [-5, -5],
    ]);
    const tiny = bigPoly("tiny", [
      [0.2, 0.2],
      [0.2, 0.4],
      [0.4, 0.4],
      [0.4, 0.2],
      [0.2, 0.2],
    ]);

    const run = (filter: GeoLibreLayer, predicate: string): FeatureCollection => {
      let out: FeatureCollection = { type: "FeatureCollection", features: [] };
      tool.run({
        layers: [a, filter],
        parameters: { layer: "a", overlay: filter.id, predicate },
        log: () => {},
        addResultLayer: (_n, g) => {
          out = g;
        },
      });
      return out;
    };

    assert.equal(run(overlap, "intersects").features.length, 1);
    assert.equal(run(far, "intersects").features.length, 0);
    assert.equal(run(far, "disjoint").features.length, 1);
    assert.equal(run(overlap, "disjoint").features.length, 0);
    // within: `a` is within `big`; contains: `a` contains `tiny`.
    assert.equal(run(big, "within").features.length, 1);
    assert.equal(run(tiny, "within").features.length, 0);
    assert.equal(run(tiny, "contains").features.length, 1);
    assert.equal(run(big, "contains").features.length, 0);
    // Empty filter layer: disjoint keeps everything, the rest keep nothing.
    const empty: GeoLibreLayer = {
      ...layer,
      id: "emptyfilter",
      name: "emptyfilter",
      geojson: { type: "FeatureCollection", features: [] },
    };
    assert.equal(run(empty, "disjoint").features.length, 1);
    assert.equal(run(empty, "intersects").features.length, 0);
  });

  it("explodes multipart geometries into single-part features", () => {
    const tool = getVectorTool("explode");
    assert.ok(tool);
    const multi: GeoLibreLayer = {
      ...layer,
      id: "multi",
      name: "Multi",
      geojson: {
        type: "FeatureCollection",
        features: [
          {
            type: "Feature",
            properties: { name: "mp" },
            geometry: {
              type: "MultiPolygon",
              coordinates: [
                [
                  [
                    [0, 0],
                    [0, 1],
                    [1, 1],
                    [1, 0],
                    [0, 0],
                  ],
                ],
                [
                  [
                    [2, 2],
                    [2, 3],
                    [3, 3],
                    [3, 2],
                    [2, 2],
                  ],
                ],
              ],
            },
          },
          {
            type: "Feature",
            properties: { name: "single" },
            geometry: {
              type: "Polygon",
              coordinates: [
                [
                  [5, 5],
                  [5, 6],
                  [6, 6],
                  [6, 5],
                  [5, 5],
                ],
              ],
            },
          },
        ],
      },
    };
    let out: FeatureCollection | null = null;
    tool.run({
      layers: [multi],
      parameters: { layer: "multi" },
      log: () => {},
      addResultLayer: (_n, g) => {
        out = g;
      },
    });
    // The 2-part MultiPolygon splits into 2 Polygons; the single Polygon stays.
    assert.equal(out!.features.length, 3);
    assert.ok(out!.features.every((f) => f.geometry.type === "Polygon"));
    // Each part keeps its parent's attributes.
    const names = out!.features.map((f) => f.properties?.name).sort();
    assert.deepEqual(names, ["mp", "mp", "single"]);
  });

  it("aggregates features by attribute with a summary statistic", () => {
    const tool = getVectorTool("aggregate");
    assert.ok(tool);
    const cell = (region: string, pop: number, x: number) => ({
      type: "Feature" as const,
      properties: { region, pop },
      geometry: {
        type: "Polygon" as const,
        coordinates: [
          [
            [x, 0],
            [x, 1],
            [x + 1, 1],
            [x + 1, 0],
            [x, 0],
          ],
        ],
      },
    });
    const parcels: GeoLibreLayer = {
      ...layer,
      id: "parcels",
      name: "Parcels",
      geojson: {
        type: "FeatureCollection",
        features: [cell("north", 10, 0), cell("north", 30, 1), cell("south", 5, 5)],
      },
    };
    const run = (parameters: Record<string, unknown>): FeatureCollection => {
      let out: FeatureCollection = { type: "FeatureCollection", features: [] };
      tool.run({
        layers: [parcels],
        parameters: { layer: "parcels", group_field: "region", ...parameters },
        log: () => {},
        addResultLayer: (_n, g) => {
          out = g;
        },
      });
      return out;
    };
    const byRegion = (fc: FeatureCollection) =>
      new Map(fc.features.map((f) => [f.properties?.region, f.properties]));

    // Count: 2 north parcels, 1 south.
    const counts = byRegion(run({ statistic: "count" }));
    assert.equal(counts.size, 2);
    assert.equal(counts.get("north")?.count, 2);
    assert.equal(counts.get("south")?.count, 1);

    // Sum of pop per region, output column named "<field>_<stat>".
    const sums = byRegion(run({ statistic: "sum", stat_field: "pop" }));
    assert.equal(sums.get("north")?.pop_sum, 40);
    assert.equal(sums.get("south")?.pop_sum, 5);

    // Mean reduces the same numeric field.
    const means = byRegion(run({ statistic: "mean", stat_field: "pop" }));
    assert.equal(means.get("north")?.pop_mean, 20);

    // min/max exercise the reduce path (no Math.min/max spread).
    const mins = byRegion(run({ statistic: "min", stat_field: "pop" }));
    assert.equal(mins.get("north")?.pop_min, 10);
    const maxes = byRegion(run({ statistic: "max", stat_field: "pop" }));
    assert.equal(maxes.get("north")?.pop_max, 30);

    // Boolean stat values coerce to 1/0 like pandas to_numeric (sum of two
    // north parcels, one true + one false → 1), not dropped as non-numeric.
    const boolLayer: GeoLibreLayer = {
      ...parcels,
      id: "boolLayer",
      geojson: {
        type: "FeatureCollection",
        features: [
          { ...cell("north", 1, 0), properties: { region: "north", flag: true } },
          { ...cell("north", 1, 1), properties: { region: "north", flag: false } },
        ],
      },
    };
    let boolOut: FeatureCollection = { type: "FeatureCollection", features: [] };
    tool.run({
      layers: [boolLayer],
      parameters: {
        layer: "boolLayer",
        group_field: "region",
        statistic: "sum",
        stat_field: "flag",
      },
      log: () => {},
      addResultLayer: (_n, g) => {
        boolOut = g;
      },
    });
    assert.equal(boolOut.features[0]?.properties?.flag_sum, 1);

    // A feature whose group value is null is skipped (no "null" bucket), matching
    // pandas groupby(dropna=True) on the sidecar.
    const withNull: GeoLibreLayer = {
      ...parcels,
      id: "withNull",
      geojson: {
        type: "FeatureCollection",
        features: [
          cell("north", 10, 0),
          { ...cell("x", 1, 5), properties: { region: null, pop: 1 } },
        ],
      },
    };
    let nullOut: FeatureCollection = { type: "FeatureCollection", features: [] };
    tool.run({
      layers: [withNull],
      parameters: { layer: "withNull", group_field: "region", statistic: "count" },
      log: () => {},
      addResultLayer: (_n, g) => {
        nullOut = g;
      },
    });
    assert.equal(nullOut.features.length, 1);
    assert.equal(nullOut.features[0].properties?.region, "north");

    // A group field absent from every feature errors (parity with the backend's
    // "not found" guard) rather than producing one empty bucket.
    let missingProduced = false;
    const missingLogs: string[] = [];
    tool.run({
      layers: [parcels],
      parameters: { layer: "parcels", group_field: "nope", statistic: "count" },
      log: (m) => missingLogs.push(m),
      addResultLayer: () => {
        missingProduced = true;
      },
    });
    assert.equal(missingProduced, false);
    assert.ok(missingLogs.some((m) => m.includes("not found")));

    // The field exists but every value is null: not an error (polygons exist),
    // an empty grouped result matching the sidecar's pandas dropna behaviour.
    const allNull: GeoLibreLayer = {
      ...parcels,
      id: "allNull",
      geojson: {
        type: "FeatureCollection",
        features: [
          { ...cell("x", 1, 0), properties: { region: null, pop: 1 } },
          { ...cell("y", 2, 5), properties: { region: null, pop: 2 } },
        ],
      },
    };
    let allNullOut: FeatureCollection | null = null;
    const allNullLogs: string[] = [];
    tool.run({
      layers: [allNull],
      parameters: { layer: "allNull", group_field: "region", statistic: "count" },
      log: (m) => allNullLogs.push(m),
      addResultLayer: (_n, g) => {
        allNullOut = g;
      },
    });
    assert.ok(allNullOut);
    assert.equal(allNullOut!.features.length, 0);
    assert.ok(allNullLogs.some((m) => m.includes("0 group(s)")));
    assert.ok(!allNullLogs.some((m) => m.includes("requires polygon")));
  });

  it("reproject defers to the Python engine on the client", () => {
    const tool = getVectorTool("reproject");
    assert.ok(tool);
    const messages: string[] = [];
    let produced = false;
    tool.run({
      layers: [layer],
      parameters: { layer: "layer-a", source_crs: "EPSG:3857" },
      log: (m) => messages.push(m),
      addResultLayer: () => {
        produced = true;
      },
    });
    // The client engine cannot reproject; it points the user at Sidecar/Pyodide
    // and produces no layer.
    assert.equal(produced, false);
    assert.ok(messages.some((m) => m.includes("Python engine")));
  });

  it("smooths polygon corners with Chaikin's algorithm", () => {
    const tool = getVectorTool("smooth");
    assert.ok(tool);
    const square: GeoLibreLayer = {
      ...layer,
      id: "square",
      name: "Square",
      geojson: {
        type: "FeatureCollection",
        features: [
          {
            type: "Feature",
            properties: { name: "s" },
            geometry: {
              type: "Polygon",
              coordinates: [
                [
                  [0, 0],
                  [0, 10],
                  [10, 10],
                  [10, 0],
                  [0, 0],
                ],
              ],
            },
          },
        ],
      },
    };
    let out: FeatureCollection | null = null;
    tool.run({
      layers: [square],
      parameters: { layer: "square", iterations: 1 },
      log: () => {},
      addResultLayer: (_n, g) => {
        out = g;
      },
    });
    assert.ok(out);
    const ring = (out!.features[0].geometry as { coordinates: number[][][] }).coordinates[0];
    // One Chaikin pass on a 4-vertex closed ring yields 8 cut points + the
    // closing vertex, and stays a closed ring (more vertices than the input).
    assert.equal(ring.length, 9);
    assert.deepEqual(ring[0], ring[ring.length - 1]);
    assert.ok(out!.features[0].geometry.type === "Polygon");
    // Properties are preserved.
    assert.equal(out!.features[0].properties?.name, "s");

    // Out-of-range iterations error rather than running.
    let produced = false;
    const logs: string[] = [];
    tool.run({
      layers: [square],
      parameters: { layer: "square", iterations: 99 },
      log: (m) => logs.push(m),
      addResultLayer: () => {
        produced = true;
      },
    });
    assert.equal(produced, false);
    assert.ok(logs.some((m) => m.includes("between 1 and 10")));

    // A malformed polygon with an empty ring must not throw; the ring stays empty.
    const malformed: GeoLibreLayer = {
      ...square,
      id: "malformed",
      geojson: {
        type: "FeatureCollection",
        features: [
          {
            type: "Feature",
            properties: {},
            geometry: { type: "Polygon", coordinates: [[]] },
          },
        ],
      },
    };
    let malformedOut: FeatureCollection | null = null;
    assert.doesNotThrow(() =>
      tool.run({
        layers: [malformed],
        parameters: { layer: "malformed", iterations: 2 },
        log: () => {},
        addResultLayer: (_n, g) => {
          malformedOut = g;
        },
      }),
    );
    assert.ok(malformedOut);
    assert.deepEqual(
      (malformedOut!.features[0].geometry as { coordinates: number[][][] }).coordinates,
      [[]],
    );

    // Z/elevation is interpolated through smoothing, not dropped.
    const line3d: GeoLibreLayer = {
      ...square,
      id: "line3d",
      geojson: {
        type: "FeatureCollection",
        features: [
          {
            type: "Feature",
            properties: {},
            geometry: {
              type: "LineString",
              coordinates: [
                [0, 0, 100],
                [0, 10, 200],
              ],
            },
          },
        ],
      },
    };
    let line3dOut: FeatureCollection | null = null;
    tool.run({
      layers: [line3d],
      parameters: { layer: "line3d", iterations: 1 },
      log: () => {},
      addResultLayer: (_n, g) => {
        line3dOut = g;
      },
    });
    const coords3d = (line3dOut!.features[0].geometry as { coordinates: number[][] }).coordinates;
    // Endpoints kept; the 1/4 cut point interpolates Z: 100*0.75 + 200*0.25 = 125.
    assert.ok(coords3d.every((c) => c.length === 3));
    assert.deepEqual(coords3d[1], [0, 2.5, 125]);

    // The feature id is preserved through smoothing.
    const withId: GeoLibreLayer = {
      ...square,
      id: "withId",
      geojson: {
        type: "FeatureCollection",
        features: [
          {
            type: "Feature",
            id: "abc",
            properties: {},
            geometry: {
              type: "Polygon",
              coordinates: [
                [
                  [0, 0],
                  [0, 10],
                  [10, 10],
                  [10, 0],
                  [0, 0],
                ],
              ],
            },
          },
        ],
      },
    };
    let idOut: FeatureCollection | null = null;
    tool.run({
      layers: [withId],
      parameters: { layer: "withId", iterations: 1 },
      log: () => {},
      addResultLayer: (_n, g) => {
        idOut = g;
      },
    });
    assert.equal(idOut!.features[0].id, "abc");
  });

  it("generates a regular grid from a bounding box", () => {
    const tool = getVectorTool("grid");
    assert.ok(tool);
    const run = (parameters: Record<string, unknown>): FeatureCollection => {
      let out: FeatureCollection = { type: "FeatureCollection", features: [] };
      tool.run({
        layers: [],
        parameters: { source: "bbox", ...parameters },
        log: () => {},
        addResultLayer: (_n, g) => {
          out = g;
        },
      });
      return out;
    };
    // A 10x10 box with 5-degree cells is a 2x2 grid of rectangles.
    const grid = run({
      west: 0,
      south: 0,
      east: 10,
      north: 10,
      cell_width: 5,
    });
    assert.equal(grid.features.length, 4);
    assert.ok(grid.features.every((f) => f.geometry.type === "Polygon"));

    // Point cells emit one centroid per cell.
    const points = run({
      west: 0,
      south: 0,
      east: 10,
      north: 10,
      cell_width: 5,
      cell_type: "point",
    });
    assert.equal(points.features.length, 4);
    assert.ok(points.features.every((f) => f.geometry.type === "Point"));

    // A degenerate box (west >= east) errors rather than producing cells.
    let produced = false;
    const logs: string[] = [];
    tool.run({
      layers: [],
      parameters: {
        source: "bbox",
        west: 10,
        south: 0,
        east: 0,
        north: 10,
        cell_width: 5,
      },
      log: (m) => logs.push(m),
      addResultLayer: () => {
        produced = true;
      },
    });
    assert.equal(produced, false);
    assert.ok(logs.some((m) => m.includes("west < east")));

    // A zero-area layer extent (single point) errors rather than logging a
    // 0-cell grid.
    const pointLayer: GeoLibreLayer = {
      ...layer,
      id: "onept",
      geojson: {
        type: "FeatureCollection",
        features: [
          {
            type: "Feature",
            properties: {},
            geometry: { type: "Point", coordinates: [5, 5] },
          },
        ],
      },
    };
    let layerProduced = false;
    const layerLogs: string[] = [];
    tool.run({
      layers: [pointLayer],
      parameters: { source: "layer", layer: "onept", cell_width: 1 },
      log: (m) => layerLogs.push(m),
      addResultLayer: () => {
        layerProduced = true;
      },
    });
    assert.equal(layerProduced, false);
    assert.ok(layerLogs.some((m) => m.includes("extent is empty")));
  });

  it("builds Voronoi cells and Delaunay triangles from points", () => {
    const tool = getVectorTool("voronoi");
    assert.ok(tool);
    const pt = (x: number, y: number) => ({
      type: "Feature" as const,
      properties: {},
      geometry: { type: "Point" as const, coordinates: [x, y] },
    });
    const points: GeoLibreLayer = {
      ...layer,
      id: "pts",
      name: "Points",
      geojson: {
        type: "FeatureCollection",
        features: [pt(0, 0), pt(10, 0), pt(0, 10), pt(10, 10), pt(5, 5)],
      },
    };
    const run = (type: string): FeatureCollection => {
      let out: FeatureCollection = { type: "FeatureCollection", features: [] };
      tool.run({
        layers: [points],
        parameters: { layer: "pts", type },
        log: () => {},
        addResultLayer: (_n, g) => {
          out = g;
        },
      });
      return out;
    };
    const cells = run("voronoi");
    assert.ok(cells.features.length > 0);
    assert.ok(
      cells.features.every(
        (f) => f.geometry.type === "Polygon" || f.geometry.type === "MultiPolygon",
      ),
    );
    const triangles = run("delaunay");
    assert.ok(triangles.features.length > 0);
    assert.ok(triangles.features.every((f) => f.geometry.type === "Polygon"));

    // Fewer than 3 points errors on both engines.
    let produced = false;
    const logs: string[] = [];
    tool.run({
      layers: [
        {
          ...points,
          id: "two",
          geojson: {
            type: "FeatureCollection",
            features: [pt(0, 0), pt(1, 1)],
          },
        },
      ],
      parameters: { layer: "two", type: "voronoi" },
      log: (m) => logs.push(m),
      addResultLayer: () => {
        produced = true;
      },
    });
    assert.equal(produced, false);
    assert.ok(logs.some((m) => m.includes("at least 3 points")));

    // Collinear points (zero-area bbox) error rather than producing a degenerate
    // or empty result.
    let collinearProduced = false;
    const collinearLogs: string[] = [];
    tool.run({
      layers: [
        {
          ...points,
          id: "collinear",
          geojson: {
            type: "FeatureCollection",
            features: [pt(0, 0), pt(0, 5), pt(0, 10)],
          },
        },
      ],
      parameters: { layer: "collinear", type: "voronoi" },
      log: (m) => collinearLogs.push(m),
      addResultLayer: () => {
        collinearProduced = true;
      },
    });
    assert.equal(collinearProduced, false);
    assert.ok(collinearLogs.some((m) => m.includes("collinear")));

    // The guard runs before the diagram-type branch, so Delaunay rejects the
    // same axis-aligned input...
    const runCollinear = (type: string, feats: typeof points.geojson.features): string[] => {
      const out: string[] = [];
      let made = false;
      tool.run({
        layers: [
          {
            ...points,
            id: "col",
            geojson: { type: "FeatureCollection", features: feats },
          },
        ],
        parameters: { layer: "col", type },
        log: (m) => out.push(m),
        addResultLayer: () => {
          made = true;
        },
      });
      assert.equal(made, false);
      return out;
    };
    assert.ok(
      runCollinear("delaunay", [pt(0, 0), pt(0, 5), pt(0, 10)]).some((m) =>
        m.includes("collinear"),
      ),
    );
    // ...and diagonally collinear points (non-zero-area bbox) are caught by the
    // empty-result guard rather than silently producing nothing.
    assert.ok(
      runCollinear("delaunay", [pt(0, 0), pt(1, 1), pt(2, 2)]).some((m) => m.includes("collinear")),
    );
  });

  it("calculates and fits layer bounds", () => {
    const messages: string[] = [];
    let fittedBounds: [number, number, number, number] | null = null;

    calculateBoundsAlgorithm.run({
      layers: [layer],
      parameters: { layer: "layer-a" },
      log: (message) => messages.push(message),
      fitBounds: (bounds) => {
        fittedBounds = bounds;
      },
    });

    assert.deepEqual(messages, ["Bounds: [-78.000000, 35.000000, -77.000000, 36.000000]"]);
    assert.deepEqual(fittedBounds, [-78, 35, -77, 36]);
  });
});
