import assert from "node:assert/strict";
import { afterEach, beforeEach, describe, it } from "node:test";
import { DEFAULT_LAYER_STYLE, type GeoLibreLayer, useAppStore } from "@geolibre/core";
import type {
  VectorControl,
  VectorLayerInfo,
  VectorLayerOptions,
  VectorLayerStyle,
} from "maplibre-gl-vector";
import { replayVectorLayer } from "../packages/plugins/src/plugins/maplibre-vector";
import {
  createVectorStoreLayer,
  isEmbeddableLocalVectorLayer,
  isVectorControlStoreLayer,
  removeVectorStoreLayers,
  resetVectorStoreSyncSuspension,
  resumeVectorStoreSync,
  runWithVectorStoreSyncSuspended,
  savedVectorState,
  suspendVectorStoreSync,
  syncVectorLayersToStore,
  unwireVectorStoreSync,
  wireVectorStoreSync,
  type VectorSyncableControl,
} from "../packages/plugins/src/plugins/vector-layer-sync";

function vectorStyle(patch: Partial<VectorLayerStyle> = {}): VectorLayerStyle {
  return {
    fillColor: "#3388ff",
    fillOpacity: 0.4,
    lineColor: "#3388ff",
    lineWidth: 2,
    circleColor: "#3388ff",
    circleRadius: 5,
    circleOpacity: 0.85,
    ...patch,
  };
}

function vectorInfo(patch: Partial<VectorLayerInfo> = {}): VectorLayerInfo {
  return {
    id: "vector-1",
    name: "countries",
    source: { kind: "url", url: "https://example.com/countries.geojson" },
    format: "geojson",
    renderMode: "geojson",
    geometryType: "polygon",
    featureCount: 258,
    bbox: [-180, -90, 180, 90],
    visible: true,
    opacity: 1,
    picker: true,
    ingestMode: "table",
    style: vectorStyle(),
    sourceId: "vector-1-source",
    layerIds: ["vector-1-fill", "vector-1-outline"],
    ...patch,
  };
}

/**
 * Recorder fake standing in for VectorControl in store->control tests.
 * getState is a static snapshot of options.collapsed: tests exercising
 * event-driven expand/collapse transitions need a stateful fake instead.
 */
function fakeControl(infos: VectorLayerInfo[] = [], options: { collapsed?: boolean } = {}) {
  const calls: { method: string; args: unknown[] }[] = [];
  const control: VectorSyncableControl = {
    getState: () => ({ collapsed: options.collapsed ?? true }),
    getLayers: () => infos,
    removeLayer: (id) => calls.push({ method: "removeLayer", args: [id] }),
    setLayerOpacity: (id, opacity) =>
      calls.push({ method: "setLayerOpacity", args: [id, opacity] }),
    setLayerVisibility: (id, visible) =>
      calls.push({ method: "setLayerVisibility", args: [id, visible] }),
    setLayerStyle: (id, style) => calls.push({ method: "setLayerStyle", args: [id, style] }),
  };
  return { control, calls };
}

function otherStoreLayer(id = "unrelated"): GeoLibreLayer {
  return {
    id,
    name: "Unrelated",
    type: "geojson",
    source: { type: "geojson" },
    visible: true,
    opacity: 1,
    style: { ...DEFAULT_LAYER_STYLE },
    metadata: {},
  };
}

describe("isEmbeddableLocalVectorLayer", () => {
  it("flags a browser-picked local file (no URL, no reload path)", () => {
    const layer = createVectorStoreLayer(
      vectorInfo({ source: { kind: "file", fileName: "local.gpkg" } }),
    );
    assert.equal(isEmbeddableLocalVectorLayer(layer), true);
  });

  it("excludes a URL-backed layer", () => {
    const layer = createVectorStoreLayer(vectorInfo());
    assert.equal(isEmbeddableLocalVectorLayer(layer), false);
  });

  it("includes a desktop path-backed layer (so a shared copy keeps its data)", () => {
    const layer = createVectorStoreLayer(
      vectorInfo({
        source: {
          kind: "file",
          fileName: "countries.gpkg",
          path: "/home/user/countries.gpkg",
        },
      }),
    );
    // It can reload from its path on the same machine, but an embedded/shared
    // copy still needs its data for a machine that lacks the file.
    assert.equal(isEmbeddableLocalVectorLayer(layer), true);
  });

  it("excludes a non-vector-control layer", () => {
    const layer = createVectorStoreLayer(
      vectorInfo({ source: { kind: "file", fileName: "local.gpkg" } }),
    );
    const plainLayer = { ...layer, metadata: {} };
    assert.equal(isEmbeddableLocalVectorLayer(plainLayer), false);
  });
});

describe("createVectorStoreLayer", () => {
  it("mirrors a URL layer as an external custom layer", () => {
    const layer = createVectorStoreLayer(vectorInfo({ opacity: 0.5, visible: false }));

    assert.equal(layer.id, "vector-1");
    assert.equal(layer.name, "countries");
    assert.equal(layer.type, "geojson");
    assert.equal(layer.visible, false);
    assert.equal(layer.opacity, 0.5);
    assert.equal(layer.source.url, "https://example.com/countries.geojson");
    assert.equal(layer.sourcePath, "https://example.com/countries.geojson");
    assert.equal(layer.metadata.externalNativeLayer, true);
    assert.equal(layer.metadata.customLayerType, "fill");
    assert.equal(layer.metadata.identifiable, false);
    assert.equal(layer.metadata.panelCollapsed, true);
    assert.equal(layer.metadata.sourceKind, "maplibre-gl-vector");
    assert.equal(layer.metadata.vectorSource, "url");
    assert.equal(layer.metadata.featureCount, 258);
    // layer-sync orders these real MapLibre style layers directly.
    assert.deepEqual(layer.metadata.nativeLayerIds, ["vector-1-fill", "vector-1-outline"]);
    assert.deepEqual(layer.metadata.sourceIds, ["vector-1-source"]);
    // fitLayer falls back to metadata.bounds for zoom-to-layer.
    assert.deepEqual(layer.metadata.bounds, [-180, -90, 180, 90]);
    assert.ok(isVectorControlStoreLayer(layer));
  });

  it("maps geometry categories onto custom layer types", () => {
    const typeFor = (geometryType: VectorLayerInfo["geometryType"]): unknown =>
      createVectorStoreLayer(vectorInfo({ geometryType })).metadata.customLayerType;

    assert.equal(typeFor("point"), "circle");
    assert.equal(typeFor("line"), "line");
    assert.equal(typeFor("polygon"), "fill");
    assert.equal(typeFor("mixed"), "custom");
    assert.equal(typeFor("unknown"), "custom");
  });

  it("uses the file name for local files and omits bounds until known", () => {
    const layer = createVectorStoreLayer(
      vectorInfo({
        source: { kind: "file", fileName: "local.gpkg" },
        bbox: undefined,
        featureCount: undefined,
      }),
    );

    assert.equal(layer.source.url, undefined);
    assert.equal(layer.sourcePath, "local.gpkg");
    assert.equal(layer.metadata.vectorSource, "file");
    assert.equal("localFileReloadable" in layer.metadata, false);
    assert.equal("bounds" in layer.metadata, false);
    assert.equal("featureCount" in layer.metadata, false);
  });

  it("persists a desktop file's absolute path and marks it reloadable", () => {
    const layer = createVectorStoreLayer(
      vectorInfo({
        source: {
          kind: "file",
          fileName: "countries.gpkg",
          path: "/home/user/data/countries.gpkg",
        },
      }),
    );

    // The absolute path (not the bare name) is persisted so restore can
    // re-read the file from disk, and the flag tells restore it can.
    assert.equal(layer.sourcePath, "/home/user/data/countries.gpkg");
    assert.equal(layer.metadata.localFileReloadable, true);
    assert.equal(layer.metadata.vectorSource, "file");
  });

  it("marks tile-rendered layers as vector-tiles", () => {
    const layer = createVectorStoreLayer(vectorInfo({ renderMode: "tiles" }));

    assert.equal(layer.type, "vector-tiles");
    assert.equal(layer.source.type, "vector");
  });

  it("exposes attribute field names in metadata for the Style panel", () => {
    const layer = createVectorStoreLayer(vectorInfo({ fields: ["name", "continent", "pop_est"] }));

    assert.deepEqual(layer.metadata.fields, ["name", "continent", "pop_est"]);
  });

  it("omits metadata.fields when the control reports none", () => {
    const layer = createVectorStoreLayer(vectorInfo({ fields: undefined }));
    assert.equal("fields" in layer.metadata, false);
  });

  it("seeds the panel labels from the control's label style", () => {
    const layer = createVectorStoreLayer(
      vectorInfo({
        style: vectorStyle({
          labelField: "name",
          labelSize: 18,
          labelColor: "#ff0000",
          labelPlacement: "line",
        }),
      }),
    );

    assert.equal(layer.style.labels.enabled, true);
    assert.equal(layer.style.labels.field, "name");
    assert.equal(layer.style.labels.size, 18);
    assert.equal(layer.style.labels.color, "#ff0000");
    assert.equal(layer.style.labels.placement, "line");
  });

  it("leaves labels disabled when the control has no label field", () => {
    const layer = createVectorStoreLayer(vectorInfo());
    assert.equal(layer.style.labels.enabled, false);
    assert.equal(layer.style.labels.field, "");
  });

  it("persists the load and style state", () => {
    const layer = createVectorStoreLayer(
      vectorInfo({
        renderMode: "tiles",
        ingestMode: "stream",
        picker: false,
        sourceLayer: "roads",
        format: "geopackage",
        style: vectorStyle({ fillColor: "#ff0000", lineWidth: 3 }),
      }),
    );

    // visible and opacity live on the top-level layer fields, not here.
    assert.deepEqual(layer.metadata.vectorState, {
      format: "geopackage",
      ingestMode: "stream",
      picker: false,
      renderMode: "tiles",
      sourceLayer: "roads",
      style: vectorStyle({ fillColor: "#ff0000", lineWidth: 3 }),
    });
  });

  it("persists the vector panel collapsed state", () => {
    const layer = createVectorStoreLayer(vectorInfo(), false);

    assert.equal(layer.metadata.panelCollapsed, false);
  });

  it("seeds the panel style from polygon fill/outline", () => {
    const layer = createVectorStoreLayer(
      vectorInfo({
        geometryType: "polygon",
        style: vectorStyle({
          fillColor: "#112233",
          fillOpacity: 0.3,
          lineColor: "#445566",
          lineWidth: 4,
        }),
      }),
    );

    assert.equal(layer.style.fillColor, "#112233");
    assert.equal(layer.style.fillOpacity, 0.3);
    assert.equal(layer.style.strokeColor, "#445566");
    assert.equal(layer.style.strokeWidth, 4);
  });

  it("seeds the panel fill from the circle style for point layers", () => {
    const layer = createVectorStoreLayer(
      vectorInfo({
        geometryType: "point",
        style: vectorStyle({
          fillColor: "#ffffff",
          circleColor: "#abc123",
          circleOpacity: 0.7,
          circleRadius: 9,
        }),
      }),
    );

    // Points fold the circle color/opacity onto GeoLibre's shared fill fields,
    // not the polygon fillColor.
    assert.equal(layer.style.fillColor, "#abc123");
    assert.equal(layer.style.fillOpacity, 0.7);
    assert.equal(layer.style.circleRadius, 9);
  });

  it("seeds a line layer from the line style and keeps default fill", () => {
    const layer = createVectorStoreLayer(
      vectorInfo({
        geometryType: "line",
        style: vectorStyle({
          fillColor: "#ffffff",
          lineColor: "#0a0b0c",
          lineWidth: 5,
        }),
      }),
    );

    // The non-point branch seeds stroke from lineColor; the shared fillColor
    // tracks the control's fillColor field (irrelevant to a line, but harmless).
    assert.equal(layer.style.strokeColor, "#0a0b0c");
    assert.equal(layer.style.strokeWidth, 5);
    assert.equal(layer.style.fillColor, "#ffffff");
  });

  it("seeds a mixed layer from the polygon fill (lossy collapse)", () => {
    const layer = createVectorStoreLayer(
      vectorInfo({
        geometryType: "mixed",
        style: vectorStyle({ fillColor: "#101112", circleColor: "#dddddd" }),
      }),
    );

    // "mixed" takes the non-point branch: the shared fillColor comes from the
    // polygon fill, so the circle color is unified with it from the first edit.
    assert.equal(layer.style.fillColor, "#101112");
  });
});

describe("syncVectorLayersToStore", () => {
  beforeEach(() => {
    useAppStore.setState({ layers: [] });
  });

  // The suspension counter is module state; a test failing mid-suspension
  // must not leave later tests silently syncing nothing.
  afterEach(() => {
    resetVectorStoreSyncSuspension();
  });

  it("adds store layers for control layers, leaving others alone", () => {
    useAppStore.getState().addLayer(otherStoreLayer());
    const { control } = fakeControl([vectorInfo(), vectorInfo({ id: "vector-2", name: "cities" })]);

    syncVectorLayersToStore(control);

    const layers = useAppStore.getState().layers;
    assert.equal(layers.length, 3);
    assert.ok(layers.some((layer) => layer.id === "vector-1"));
    assert.ok(layers.some((layer) => layer.id === "vector-2"));
    assert.ok(layers.some((layer) => layer.id === "unrelated"));
  });

  it("removes store layers whose vector layers are gone", () => {
    const { control } = fakeControl([vectorInfo()]);
    syncVectorLayersToStore(control);
    assert.equal(useAppStore.getState().layers.length, 1);

    syncVectorLayersToStore(fakeControl([]).control);
    assert.equal(useAppStore.getState().layers.length, 0);
  });

  // Restore replays each layer of a multi-layer container independently, so one
  // unreachable feed used to delete those layers from the project outright
  // (opengeos/GeoLibre discussion #1757). A failed replay is not a removal.
  it("keeps preserved layers the control does not have", () => {
    const { control } = fakeControl([vectorInfo(), vectorInfo({ id: "vector-2", name: "cities" })]);
    syncVectorLayersToStore(control);
    assert.equal(useAppStore.getState().layers.length, 2);

    // vector-2 failed to replay: absent from the control, but still the user's.
    syncVectorLayersToStore(fakeControl([vectorInfo()]).control, {
      preserveLayerIds: new Set(["vector-2"]),
    });

    assert.deepEqual(
      useAppStore.getState().layers.map((layer) => layer.id),
      ["vector-1", "vector-2"],
    );
  });

  it("still prunes layers outside the preserved set", () => {
    const { control } = fakeControl([vectorInfo(), vectorInfo({ id: "vector-2", name: "cities" })]);
    syncVectorLayersToStore(control);

    // Only vector-2 failed; vector-1 was genuinely removed at the control.
    syncVectorLayersToStore(fakeControl([]).control, {
      preserveLayerIds: new Set(["vector-2"]),
    });

    assert.deepEqual(
      useAppStore.getState().layers.map((layer) => layer.id),
      ["vector-2"],
    );
  });

  it("refreshes changed fields but preserves panel renames", () => {
    const { control } = fakeControl([vectorInfo()]);
    syncVectorLayersToStore(control);
    useAppStore.getState().updateLayer("vector-1", { name: "My Countries" });

    syncVectorLayersToStore(
      fakeControl([
        vectorInfo({
          bbox: [0, 0, 1, 1],
          opacity: 0.4,
          // A render-mode switch recreates the map layers.
          renderMode: "tiles",
          layerIds: ["vector-1-fill", "vector-1-outline"],
        }),
      ]).control,
    );

    const layer = useAppStore.getState().layers[0];
    assert.equal(layer.name, "My Countries");
    assert.equal(layer.opacity, 0.4);
    assert.deepEqual(layer.metadata.bounds, [0, 0, 1, 1]);
    assert.equal(layer.source.type, "vector");
    assert.equal(layer.type, "vector-tiles");
  });

  it("refreshes the saved panel collapsed state", () => {
    const { control } = fakeControl([vectorInfo()]);
    syncVectorLayersToStore(control);
    assert.equal(useAppStore.getState().layers[0].metadata.panelCollapsed, true);

    syncVectorLayersToStore(fakeControl([vectorInfo()], { collapsed: false }).control);

    assert.equal(useAppStore.getState().layers[0].metadata.panelCollapsed, false);
  });

  it("flips panelCollapsed on store layers when an expand event syncs", () => {
    // Stateful stand-in for the production expand/collapse wiring: the
    // handler mirrors panelStateSyncHandler in maplibre-vector.ts, and
    // expand() flips the state before notifying, matching the verified
    // maplibre-gl-vector event ordering.
    let collapsed = true;
    const handlers: Array<() => void> = [];
    const control: VectorSyncableControl = {
      getState: () => ({ collapsed }),
      getLayers: () => [vectorInfo()],
      removeLayer: () => {},
      setLayerOpacity: () => {},
      setLayerVisibility: () => {},
      setLayerStyle: () => {},
    };
    handlers.push(() => syncVectorLayersToStore(control));
    const expand = () => {
      collapsed = false;
      for (const handler of handlers) handler();
    };

    syncVectorLayersToStore(control);
    assert.equal(useAppStore.getState().layers[0].metadata.panelCollapsed, true);

    expand();

    assert.equal(useAppStore.getState().layers[0].metadata.panelCollapsed, false);
  });

  it("does not touch an existing layer when nothing changed", () => {
    const { control } = fakeControl([vectorInfo()]);
    syncVectorLayersToStore(control);
    const before = useAppStore.getState().layers[0];

    // A second sync with an identical snapshot builds fresh source/metadata
    // objects; the deep comparison must not report them as changed.
    syncVectorLayersToStore(fakeControl([vectorInfo()]).control);

    assert.equal(useAppStore.getState().layers[0], before);
  });

  it("drops a loaded embeddedGeoJSON blob on sync (re-materialized at save)", () => {
    // embeddedGeoJSON is not kept live in the store: a project loads it, restore
    // replays it into the control, and this sync then replaces the layer's
    // metadata without it. The web Save flow re-materializes it from the control
    // (getLayerGeoJSON), so the stale loaded blob must not survive here.
    const info = vectorInfo({
      source: { kind: "file", fileName: "local.geojson" },
    });
    const { control } = fakeControl([info]);
    syncVectorLayersToStore(control);
    useAppStore.getState().updateLayer("vector-1", {
      metadata: {
        ...useAppStore.getState().layers[0].metadata,
        embeddedGeoJSON: { type: "FeatureCollection" as const, features: [] },
      },
    });

    syncVectorLayersToStore(fakeControl([info]).control);

    assert.equal("embeddedGeoJSON" in useAppStore.getState().layers[0].metadata, false);
  });

  it("does nothing while sync is suspended", () => {
    const { control } = fakeControl([vectorInfo()]);
    runWithVectorStoreSyncSuspended(() => {
      syncVectorLayersToStore(control);
    });
    assert.equal(useAppStore.getState().layers.length, 0);
  });

  it("stays suspended across an async window until resumed", () => {
    // restoreVectorLayers holds the suspension across addData's async
    // loads (the control only lists a layer once its data has loaded), so
    // the pair must compose without a synchronous wrapper.
    const { control } = fakeControl([vectorInfo()]);
    suspendVectorStoreSync();
    syncVectorLayersToStore(control);
    assert.equal(useAppStore.getState().layers.length, 0);

    resumeVectorStoreSync();
    syncVectorLayersToStore(control);
    assert.equal(useAppStore.getState().layers.length, 1);

    // A resume racing a teardown reset must not underflow the counter
    // into a sticky suspension.
    resetVectorStoreSyncSuspension();
    resumeVectorStoreSync();
    syncVectorLayersToStore(fakeControl([]).control);
    assert.equal(useAppStore.getState().layers.length, 0);
  });
});

describe("wireVectorStoreSync", () => {
  beforeEach(() => {
    useAppStore.setState({ layers: [] });
  });

  afterEach(() => {
    unwireVectorStoreSync();
    resetVectorStoreSyncSuspension();
  });

  it("applies panel visibility and opacity changes through the control", () => {
    const { control, calls } = fakeControl([vectorInfo()]);
    syncVectorLayersToStore(control);
    wireVectorStoreSync(control);

    useAppStore.getState().updateLayer("vector-1", { visible: false });
    useAppStore.getState().updateLayer("vector-1", { opacity: 0.25 });

    assert.deepEqual(calls, [
      { method: "setLayerVisibility", args: ["vector-1", false] },
      { method: "setLayerOpacity", args: ["vector-1", 0.25] },
    ]);
  });

  it("applies panel style changes through the control", () => {
    const { control, calls } = fakeControl([vectorInfo()]);
    syncVectorLayersToStore(control);
    wireVectorStoreSync(control);

    useAppStore.getState().setLayerStyle("vector-1", { fillColor: "#ff0000" });

    // GeoLibre's shared fillColor/strokeColor/fillOpacity/strokeWidth map onto
    // the control's per-geometry fill/line/circle style; the unedited fields
    // come from the style seeded off the control's own style. The color
    // expression fields are undefined for a single-color (non-data-driven) edit.
    const expectedStyle = {
      fillColor: "#ff0000",
      fillOpacity: 0.4,
      lineColor: "#3388ff",
      lineWidth: 2,
      circleColor: "#ff0000",
      circleOpacity: 0.4,
      circleRadius: 5,
      fillColorExpression: undefined,
      lineColorExpression: undefined,
      circleColorExpression: undefined,
      // Point renderer fields default through from DEFAULT_LAYER_STYLE
      // ("single" -> "circle"); they ride along on every style push.
      pointMode: "circle",
      heatmapRadius: 30,
      heatmapIntensity: 1,
      clusterRadius: 50,
      clusterMaxZoom: 14,
      // Label fields default through from DEFAULT_LAYER_STYLE.labels; labelField
      // is empty because labels start disabled.
      labelField: "",
      labelSize: 13,
      labelColor: "#111827",
      labelHaloColor: "#ffffff",
      labelHaloWidth: 1.5,
      labelPlacement: "point",
      labelAllowOverlap: false,
      // Extrusion fields default through from DEFAULT_LAYER_STYLE; the height is
      // the chosen property scaled (default property "height", scale 1) and the
      // color resolves to a flat value so its expression field is undefined.
      extrusionEnabled: false,
      extrusionColor: "#3b82f6",
      extrusionColorExpression: undefined,
      extrusionOpacity: 0.8,
      extrusionHeight: ["*", ["to-number", ["get", "height"], 0], 1],
      extrusionBase: 0,
    };
    assert.deepEqual(calls, [{ method: "setLayerStyle", args: ["vector-1", expectedStyle] }]);

    // The control-seed style is kept in sync so a saved project restores the
    // edited colors (restoreVectorLayers seeds addData from vectorState.style).
    const stored = useAppStore.getState().layers[0];
    assert.deepEqual((stored.metadata.vectorState as { style: unknown }).style, expectedStyle);
  });

  it("pushes the point renderer (heatmap/cluster) through the control", () => {
    const { control, calls } = fakeControl([vectorInfo({ geometryType: "point" })]);
    syncVectorLayersToStore(control);
    wireVectorStoreSync(control);

    useAppStore.getState().setLayerStyle("vector-1", {
      pointRenderer: "cluster",
      clusterRadius: 40,
    });

    assert.equal(calls.length, 1);
    const pushed = calls[0].args[1] as VectorLayerStyle;
    // GeoLibre's "cluster" maps to the control's pointMode, with cluster params.
    assert.equal(pushed.pointMode, "cluster");
    assert.equal(pushed.clusterRadius, 40);
  });

  it("pushes a categorized color expression through the control", () => {
    const { control, calls } = fakeControl([vectorInfo()]);
    syncVectorLayersToStore(control);
    wireVectorStoreSync(control);

    useAppStore.getState().setLayerStyle("vector-1", {
      vectorStyleMode: "categorized",
      vectorStyleProperty: "continent",
      vectorStyleStops: [
        { value: "Asia", color: "#ff0000" },
        { value: "Europe", color: "#00ff00" },
      ],
    });

    assert.equal(calls.length, 1);
    const [method, args] = [calls[0].method, calls[0].args];
    assert.equal(method, "setLayerStyle");
    const pushed = args[1] as VectorLayerStyle;
    // The fill (and circle) color becomes a MapLibre `match` expression on the
    // chosen attribute, with the flat fillColor as the fallback.
    const matchExpr = [
      "match",
      ["to-string", ["get", "continent"]],
      "Asia",
      "#ff0000",
      "Europe",
      "#00ff00",
      "#3388ff",
    ];
    assert.deepEqual(pushed.fillColorExpression, matchExpr);
    assert.deepEqual(pushed.circleColorExpression, matchExpr);
    // Polygon outlines keep the flat stroke color; only line geometry takes the
    // categorized color, expressed via a geometry-type case.
    assert.deepEqual(pushed.lineColorExpression, [
      "case",
      ["==", ["geometry-type"], "Polygon"],
      "#3388ff",
      matchExpr,
    ]);
  });

  it("clears the color expression when reverting to a single color", () => {
    const { control, calls } = fakeControl([vectorInfo()]);
    syncVectorLayersToStore(control);
    wireVectorStoreSync(control);

    useAppStore.getState().setLayerStyle("vector-1", {
      vectorStyleMode: "categorized",
      vectorStyleProperty: "continent",
      vectorStyleStops: [{ value: "Asia", color: "#ff0000" }],
    });
    useAppStore.getState().setLayerStyle("vector-1", {
      vectorStyleMode: "single",
    });

    // Two pushes: one applying the expression, one reverting to flat color.
    assert.equal(calls.length, 2);
    const reverted = calls[1].args[1] as VectorLayerStyle;
    assert.equal(reverted.fillColorExpression, undefined);
    assert.equal(reverted.lineColorExpression, undefined);
    assert.equal(reverted.circleColorExpression, undefined);
  });

  it("pushes a Show-labels toggle through the control", () => {
    const { control, calls } = fakeControl([vectorInfo()]);
    syncVectorLayersToStore(control);
    wireVectorStoreSync(control);

    useAppStore.getState().setLayerStyle("vector-1", {
      labels: {
        ...DEFAULT_LAYER_STYLE.labels,
        enabled: true,
        field: "name",
        size: 18,
      },
    });

    assert.equal(calls.length, 1);
    const pushed = calls[0].args[1] as VectorLayerStyle;
    assert.equal(pushed.labelField, "name");
    assert.equal(pushed.labelSize, 18);
  });

  it("clears the control label field when labels are disabled", () => {
    const { control, calls } = fakeControl([
      vectorInfo({ style: vectorStyle({ labelField: "name" }) }),
    ]);
    syncVectorLayersToStore(control);
    wireVectorStoreSync(control);

    // The layer was seeded with labels on; turning the checkbox off must push an
    // empty labelField so the control removes its symbol layer.
    useAppStore.getState().setLayerStyle("vector-1", {
      labels: { ...DEFAULT_LAYER_STYLE.labels, enabled: false, field: "name" },
    });

    assert.equal(calls.length, 1);
    const pushed = calls[0].args[1] as VectorLayerStyle;
    assert.equal(pushed.labelField, "");
  });

  it("does not touch the control for GeoLibre-only style fields", () => {
    const { control, calls } = fakeControl([vectorInfo()]);
    syncVectorLayersToStore(control);
    wireVectorStoreSync(control);

    // textColor has no VectorLayerStyle equivalent, so the mapped style is
    // unchanged and no setLayerStyle is pushed.
    useAppStore.getState().setLayerStyle("vector-1", { textColor: "#abcdef" });

    assert.deepEqual(calls, []);
  });

  it("drops the control layer when the panel removes the layer", () => {
    const { control, calls } = fakeControl([vectorInfo()]);
    syncVectorLayersToStore(control);
    wireVectorStoreSync(control);

    useAppStore.getState().removeLayer("vector-1");

    assert.deepEqual(calls, [{ method: "removeLayer", args: ["vector-1"] }]);
  });

  it("does not echo control-driven syncs back at the control", () => {
    const { control, calls } = fakeControl([vectorInfo()]);
    syncVectorLayersToStore(control);
    wireVectorStoreSync(control);

    // A layer removed in the control's own panel: the event-driven sync
    // removes the store layer, which must not bounce a removeLayer back.
    syncVectorLayersToStore(fakeControl([]).control);

    assert.equal(useAppStore.getState().layers.length, 0);
    assert.deepEqual(calls, []);
  });

  it("ignores store changes that touch no vector layers", () => {
    const { control, calls } = fakeControl([vectorInfo()]);
    syncVectorLayersToStore(control);
    wireVectorStoreSync(control);

    useAppStore.getState().addLayer(otherStoreLayer());
    useAppStore.getState().updateLayer("unrelated", { opacity: 0.5 });

    assert.deepEqual(calls, []);
  });
});

describe("wireVectorStoreSync with layer groups", () => {
  beforeEach(() => {
    useAppStore.setState({ layers: [], layerGroups: [] });
  });

  afterEach(() => {
    unwireVectorStoreSync();
    resetVectorStoreSyncSuspension();
    useAppStore.setState({ layerGroups: [] });
  });

  it("multiplies the layer and parent group opacities", () => {
    const { control, calls } = fakeControl([vectorInfo({ opacity: 0.5 })]);
    syncVectorLayersToStore(control);
    wireVectorStoreSync(control);

    const groupId = useAppStore.getState().addLayerGroup("Group 1", ["vector-1"]);
    calls.length = 0;
    useAppStore.getState().setLayerGroupOpacity(groupId, 0.4);

    assert.deepEqual(calls, [{ method: "setLayerOpacity", args: ["vector-1", 0.2] }]);
  });

  it("updates the control from both opacity sliders in a nested group chain", () => {
    const { control, calls } = fakeControl([vectorInfo()]);
    syncVectorLayersToStore(control);
    wireVectorStoreSync(control);

    const childId = useAppStore.getState().addLayerGroup("Child", ["vector-1"]);
    const parentId = useAppStore.getState().addLayerGroup("Parent");
    useAppStore.getState().moveLayerGroupToGroup(childId, parentId);
    calls.length = 0;

    useAppStore.getState().setLayerGroupOpacity(childId, 0.5);
    assert.deepEqual(calls, [{ method: "setLayerOpacity", args: ["vector-1", 0.5] }]);

    calls.length = 0;
    useAppStore.getState().setLayerGroupOpacity(parentId, 0.4);
    assert.deepEqual(calls, [{ method: "setLayerOpacity", args: ["vector-1", 0.2] }]);

    calls.length = 0;
    useAppStore.getState().setLayerGroupOpacity(childId, 0.25);
    assert.deepEqual(calls, [{ method: "setLayerOpacity", args: ["vector-1", 0.1] }]);
  });

  it("keeps the layer opacity when the control echoes a group fold", () => {
    const { control } = fakeControl([vectorInfo()]);
    syncVectorLayersToStore(control);
    wireVectorStoreSync(control);

    const groupId = useAppStore.getState().addLayerGroup("Group 1", ["vector-1"]);
    useAppStore.getState().setLayerGroupOpacity(groupId, 0.4);

    syncVectorLayersToStore(fakeControl([vectorInfo({ opacity: 0.4 })]).control);

    assert.equal(useAppStore.getState().layers[0].opacity, 1);
  });

  it("keeps the layer visibility when the control echoes a group fold", () => {
    const { control } = fakeControl([vectorInfo()]);
    syncVectorLayersToStore(control);
    wireVectorStoreSync(control);

    const groupId = useAppStore.getState().addLayerGroup("Group 1", ["vector-1"]);
    useAppStore.getState().setLayerGroupVisibility(groupId, false);

    syncVectorLayersToStore(fakeControl([vectorInfo({ visible: false })]).control);

    assert.equal(useAppStore.getState().layers[0].visible, true);
  });

  it("records a control-side opacity edit made under a faded group", () => {
    const { control } = fakeControl([vectorInfo({ opacity: 0.5 })]);
    syncVectorLayersToStore(control);
    wireVectorStoreSync(control);

    const groupId = useAppStore.getState().addLayerGroup("Group 1", ["vector-1"]);
    useAppStore.getState().setLayerGroupOpacity(groupId, 0.4);

    const edited = fakeControl([vectorInfo({ opacity: 0.9 })]);
    syncVectorLayersToStore(edited.control);

    assert.equal(useAppStore.getState().layers[0].opacity, 0.9);
    assert.equal(edited.calls.length, 1);
    assert.equal(edited.calls[0].method, "setLayerOpacity");
    assert.equal(edited.calls[0].args[0], "vector-1");
    assert.ok(Math.abs((edited.calls[0].args[1] as number) - 0.36) < 1e-9);
  });

  it("reapplies a hidden parent after a control-side visibility edit", () => {
    const { control } = fakeControl([vectorInfo()]);
    syncVectorLayersToStore(control);
    wireVectorStoreSync(control);

    const groupId = useAppStore.getState().addLayerGroup("Group 1", ["vector-1"]);
    useAppStore.getState().setLayerGroupVisibility(groupId, false);

    const edited = fakeControl([vectorInfo({ visible: true })]);
    syncVectorLayersToStore(edited.control);

    assert.equal(useAppStore.getState().layers[0].visible, true);
    assert.deepEqual(edited.calls, [{ method: "setLayerVisibility", args: ["vector-1", false] }]);
  });

  it("hides and shows a vector through a nested parent group", () => {
    const { control, calls } = fakeControl([vectorInfo()]);
    syncVectorLayersToStore(control);
    wireVectorStoreSync(control);

    const childId = useAppStore.getState().addLayerGroup("Child", ["vector-1"]);
    const parentId = useAppStore.getState().addLayerGroup("Parent");
    useAppStore.getState().moveLayerGroupToGroup(childId, parentId);
    calls.length = 0;

    useAppStore.getState().setLayerGroupVisibility(parentId, false);
    useAppStore.getState().setLayerGroupVisibility(parentId, true);

    assert.deepEqual(calls, [
      { method: "setLayerVisibility", args: ["vector-1", false] },
      { method: "setLayerVisibility", args: ["vector-1", true] },
    ]);
  });
});

describe("replayVectorLayer with layer groups", () => {
  const groups = new Map([
    [
      "child-group",
      {
        id: "child-group",
        name: "Child",
        parentId: "parent-group",
        collapsed: false,
        visible: true,
        opacity: 0.5,
      },
    ],
    [
      "parent-group",
      {
        id: "parent-group",
        name: "Parent",
        collapsed: false,
        visible: false,
        opacity: 0.25,
      },
    ],
  ]);
  const embedded = {
    type: "FeatureCollection" as const,
    features: [],
  };
  const cases = [
    {
      name: "URL",
      info: vectorInfo(),
      source: "https://example.com/countries.geojson",
      options: {},
    },
    {
      name: "local file",
      info: vectorInfo({
        source: {
          kind: "file",
          fileName: "countries.geojson",
          path: "/data/countries.geojson",
        },
      }),
      source: new File([""], "countries.geojson", { type: "application/geo+json" }),
      options: { localPath: "/data/countries.geojson" },
    },
    {
      name: "embedded GeoJSON",
      info: vectorInfo({
        source: { kind: "file", fileName: "countries.geojson" },
      }),
      source: embedded,
      options: {},
    },
  ] as const;

  beforeEach(() => {
    unwireVectorStoreSync();
    useAppStore.setState({ layers: [], layerGroups: [] });
  });

  afterEach(() => {
    unwireVectorStoreSync();
    resetVectorStoreSyncSuspension();
    useAppStore.setState({ layers: [], layerGroups: [] });
  });

  for (const restoreCase of cases) {
    it(`restores folded opacity and visibility for a ${restoreCase.name} source`, async () => {
      const layer = {
        ...createVectorStoreLayer(restoreCase.info),
        groupId: "child-group",
        opacity: 0.8,
        visible: true,
      };
      useAppStore.setState({ layers: [layer], layerGroups: [...groups.values()] });
      let addDataCall:
        | {
            source: unknown;
            options: VectorLayerOptions | undefined;
          }
        | undefined;
      const control = {
        addData: async (source: unknown, options?: VectorLayerOptions) => {
          addDataCall = { source, options };
          return restoreCase.info;
        },
      } as unknown as VectorControl;

      await replayVectorLayer(control, layer, restoreCase.source, groups, restoreCase.options);

      assert.ok(addDataCall);
      assert.equal(addDataCall.source, restoreCase.source);
      assert.equal(addDataCall.options?.opacity, 0.1);
      assert.equal(addDataCall.options?.visible, false);

      // The control's first post-restore snapshot echoes the folded render
      // values. The child's own settings must remain ready for its groups to
      // be shown or unfaded later.
      syncVectorLayersToStore(
        fakeControl([
          {
            ...restoreCase.info,
            opacity: 0.1,
            visible: false,
          },
        ]).control,
      );
      assert.equal(useAppStore.getState().layers[0].opacity, 0.8);
      assert.equal(useAppStore.getState().layers[0].visible, true);
    });
  }

  it("clears restored render tracking after group overrides are removed", async () => {
    const info = vectorInfo();
    const layer = {
      ...createVectorStoreLayer(info),
      groupId: "child-group",
      opacity: 0.8,
      visible: true,
    };
    useAppStore.setState({ layers: [layer], layerGroups: [...groups.values()] });
    const control = {
      addData: async () => info,
    } as unknown as VectorControl;
    await replayVectorLayer(control, layer, "https://example.com/countries.geojson", groups);

    // With the groups gone, this genuine edit needs no render override. It
    // clears the old folded value (0.1) from the echo tracker.
    useAppStore.setState({ layerGroups: [] });
    syncVectorLayersToStore(fakeControl([vectorInfo({ opacity: 0.6 })]).control);
    assert.equal(useAppStore.getState().layers[0].opacity, 0.6);

    // Returning to the former folded value is still a genuine edit and must
    // reach the child's store state instead of being swallowed as an echo.
    syncVectorLayersToStore(fakeControl([vectorInfo({ opacity: 0.1 })]).control);
    assert.equal(useAppStore.getState().layers[0].opacity, 0.1);
  });
});

describe("removeVectorStoreLayers", () => {
  beforeEach(() => {
    useAppStore.setState({ layers: [] });
  });

  afterEach(() => {
    unwireVectorStoreSync();
    resetVectorStoreSyncSuspension();
  });

  it("prunes vector layers without echoing removals at the control", () => {
    const { control, calls } = fakeControl([vectorInfo()]);
    syncVectorLayersToStore(control);
    useAppStore.getState().addLayer(otherStoreLayer());
    wireVectorStoreSync(control);

    removeVectorStoreLayers();

    const layers = useAppStore.getState().layers;
    assert.equal(layers.length, 1);
    assert.equal(layers[0].id, "unrelated");
    assert.deepEqual(calls, []);
  });
});

describe("savedVectorState", () => {
  it("round-trips the state persisted by createVectorStoreLayer", () => {
    const layer = createVectorStoreLayer(
      vectorInfo({
        renderMode: "tiles",
        ingestMode: "stream",
        picker: false,
        sourceLayer: "roads",
        format: "geopackage",
        style: vectorStyle({ circleRadius: 8, circleOpacity: 0.5 }),
      }),
    );

    assert.deepEqual(savedVectorState(layer), {
      format: "geopackage",
      ingestMode: "stream",
      picker: false,
      renderMode: "tiles",
      sourceLayer: "roads",
      style: vectorStyle({ circleRadius: 8, circleOpacity: 0.5 }),
    });
  });

  it("restores data-driven color expressions from the persisted style", () => {
    // A saved categorized/graduated style persists a color expression in
    // vectorState.style; restore must hand it back so the control seeds the
    // expression and the reopened project renders the data-driven colors.
    const matchExpr = ["match", ["to-string", ["get", "continent"]], "Asia", "#ff0000", "#3388ff"];
    const layer = createVectorStoreLayer(vectorInfo());
    (layer.metadata.vectorState as Record<string, unknown>).style = {
      ...vectorStyle(),
      fillColorExpression: matchExpr,
      circleColorExpression: matchExpr,
    };

    const restored = savedVectorState(layer).style;
    assert.ok(restored != null);
    assert.deepEqual(restored.fillColorExpression, matchExpr);
    assert.deepEqual(restored.circleColorExpression, matchExpr);
    // No lineColorExpression was persisted, so none is restored.
    assert.equal("lineColorExpression" in restored, false);
  });

  it("restores attribute label fields from the persisted style", () => {
    const layer = createVectorStoreLayer(vectorInfo());
    (layer.metadata.vectorState as Record<string, unknown>).style = {
      ...vectorStyle(),
      labelField: "name",
      labelSize: 18,
      labelColor: "#ff0000",
      labelHaloColor: "#000000",
      labelHaloWidth: 2,
      labelPlacement: "line",
      labelAllowOverlap: true,
    };

    const restored = savedVectorState(layer).style;
    assert.ok(restored != null);
    assert.equal(restored.labelField, "name");
    assert.equal(restored.labelSize, 18);
    assert.equal(restored.labelColor, "#ff0000");
    assert.equal(restored.labelHaloColor, "#000000");
    assert.equal(restored.labelHaloWidth, 2);
    assert.equal(restored.labelPlacement, "line");
    assert.equal(restored.labelAllowOverlap, true);
  });

  it("drops a malformed label field from a hand-edited project file", () => {
    const layer = createVectorStoreLayer(vectorInfo());
    (layer.metadata.vectorState as Record<string, unknown>).style = {
      fillColor: "#123456",
      labelField: "x".repeat(201),
      labelPlacement: "diagonal",
      labelHaloWidth: -3,
    };

    const restored = savedVectorState(layer).style;
    assert.ok(restored != null);
    assert.equal(restored.fillColor, "#123456");
    assert.equal("labelField" in restored, false);
    assert.equal("labelPlacement" in restored, false);
    assert.equal("labelHaloWidth" in restored, false);
  });

  it("drops a malformed color expression without throwing", () => {
    // A circular (or pathologically deep) array from a hand-edited project file
    // would make JSON.stringify throw; the guard must reject it and keep the
    // rest of the restore working rather than aborting it. Empty arrays are
    // rejected too, since [] is not a valid MapLibre expression.
    const circular: unknown[] = [];
    circular.push(circular);
    const layer = createVectorStoreLayer(vectorInfo());
    (layer.metadata.vectorState as Record<string, unknown>).style = {
      fillColor: "#123456",
      fillColorExpression: circular,
      lineColorExpression: [],
    };

    const restored = savedVectorState(layer).style;
    assert.ok(restored != null);
    assert.equal(restored.fillColor, "#123456");
    assert.equal("fillColorExpression" in restored, false);
    assert.equal("lineColorExpression" in restored, false);
  });

  it("drops malformed fields from hand-edited project files", () => {
    const layer = createVectorStoreLayer(vectorInfo());
    layer.metadata.vectorState = {
      renderMode: "hologram",
      ingestMode: "teleport",
      picker: "yes",
      // Length-capped like colors: legitimate names are short.
      sourceLayer: "x".repeat(201),
      format: "y".repeat(51),
      style: {
        fillColor: 7,
        fillOpacity: 2,
        // Colors are length-capped so a hand-edited project file cannot
        // smuggle arbitrary blobs into paint properties.
        lineColor: `#${"f".repeat(200)}`,
        lineWidth: -1,
        circleRadius: Number.NaN,
      },
    };

    assert.deepEqual(savedVectorState(layer), {});
  });

  it("keeps the well-formed subset of a partially valid style", () => {
    const layer = createVectorStoreLayer(vectorInfo());
    layer.metadata.vectorState = {
      renderMode: "geojson",
      style: { fillColor: "#123456", fillOpacity: 5 },
    };

    assert.deepEqual(savedVectorState(layer), {
      renderMode: "geojson",
      style: { fillColor: "#123456" },
    });
  });

  it("returns no overrides when the metadata is missing", () => {
    const layer = createVectorStoreLayer(vectorInfo());
    delete layer.metadata.vectorState;
    assert.deepEqual(savedVectorState(layer), {});
  });

  it("restores 3D extrusion fields from the persisted style", () => {
    const heightExpr = ["*", ["to-number", ["get", "height"], 0], 2];
    const colorExpr = ["match", ["to-string", ["get", "kind"]], "tower", "#ff0000", "#3388ff"];
    const layer = createVectorStoreLayer(vectorInfo());
    (layer.metadata.vectorState as Record<string, unknown>).style = {
      ...vectorStyle(),
      extrusionEnabled: true,
      extrusionColor: "#abcdef",
      extrusionColorExpression: colorExpr,
      extrusionOpacity: 0.6,
      extrusionBase: 3,
      extrusionHeight: heightExpr,
    };

    const restored = savedVectorState(layer).style;
    assert.ok(restored != null);
    assert.equal(restored.extrusionEnabled, true);
    assert.equal(restored.extrusionColor, "#abcdef");
    assert.deepEqual(restored.extrusionColorExpression, colorExpr);
    assert.equal(restored.extrusionOpacity, 0.6);
    assert.equal(restored.extrusionBase, 3);
    assert.deepEqual(restored.extrusionHeight, heightExpr);
  });

  it("restores a flat numeric extrusion height", () => {
    const layer = createVectorStoreLayer(vectorInfo());
    (layer.metadata.vectorState as Record<string, unknown>).style = {
      ...vectorStyle(),
      extrusionEnabled: true,
      extrusionHeight: 12,
    };

    const restored = savedVectorState(layer).style;
    assert.ok(restored != null);
    assert.equal(restored.extrusionHeight, 12);
  });

  it("drops malformed extrusion fields from a hand-edited project file", () => {
    const circular: unknown[] = [];
    circular.push(circular);
    const layer = createVectorStoreLayer(vectorInfo());
    (layer.metadata.vectorState as Record<string, unknown>).style = {
      fillColor: "#123456",
      // MapLibre clamps a negative height/base to 0, so both are rejected.
      extrusionHeight: -50,
      extrusionBase: -10,
      // Opacity must be a 0-1 fraction.
      extrusionOpacity: 5,
      // A circular array cannot serialize, so the height expression is dropped.
      extrusionColorExpression: circular,
      extrusionColor: `#${"f".repeat(200)}`,
    };

    const restored = savedVectorState(layer).style;
    assert.ok(restored != null);
    assert.equal(restored.fillColor, "#123456");
    assert.equal("extrusionHeight" in restored, false);
    assert.equal("extrusionBase" in restored, false);
    assert.equal("extrusionOpacity" in restored, false);
    assert.equal("extrusionColorExpression" in restored, false);
    assert.equal("extrusionColor" in restored, false);
  });
});
