import { describe, it, expect } from "vitest";
import { nodesToFeatureCollection, filterByNodeType, buildNeighborEdges, neighborFocusIds, buildFocusedNeighborEdges } from "../../../src/features/map/node-geojson";
import type { NodeSummary, NodeNeighbor } from "../../../src/features/nodes/types";

function node(overrides: Partial<NodeSummary>): NodeSummary {
  return {
    id: "n1",
    publicKey: "pk",
    nodeType: 1,
    nodeTypeName: "repeater",
    name: "Node 1",
    lat: 45,
    lng: -75,
    iatas: [],
    ...overrides,
  };
}

describe("nodesToFeatureCollection", () => {
  it("returns an empty FeatureCollection for no nodes", () => {
    const fc = nodesToFeatureCollection([]);
    expect(fc.type).toBe("FeatureCollection");
    expect(fc.features).toEqual([]);
  });

  it("maps a located node to a Point feature in [lng, lat] order", () => {
    const fc = nodesToFeatureCollection([node({ lat: 45.3, lng: -75.6 })]);
    expect(fc.features).toHaveLength(1);
    expect(fc.features[0]!.geometry).toEqual({ type: "Point", coordinates: [-75.6, 45.3] });
  });

  it("keeps whole-degree coordinates intact (the API sends decimal degrees, never microdegrees)", () => {
    const fc = nodesToFeatureCollection([node({ lat: 51, lng: -114 })]);
    expect(fc.features[0]!.geometry.coordinates).toEqual([-114, 51]);
  });

  it("carries id/name/nodeTypeName/isObserver as feature properties", () => {
    const fc = nodesToFeatureCollection([
      node({ id: "abc", name: "Relay A", nodeType: 2, nodeTypeName: "sensor" }),
    ]);
    expect(fc.features[0]!.properties).toEqual({
      id: "abc",
      name: "Relay A",
      nodeTypeName: "sensor",
      isObserver: false,
    });
  });

  it("defaults isObserver to false and reflects it when set", () => {
    const fc = nodesToFeatureCollection([
      node({ id: "plain" }),
      node({ id: "obs", isObserver: true }),
    ]);
    expect(fc.features[0]!.properties.isObserver).toBe(false);
    expect(fc.features[1]!.properties.isObserver).toBe(true);
  });

  it("drops nodes missing lat or lng", () => {
    const fc = nodesToFeatureCollection([
      node({ id: "a", lat: null, lng: -75 }),
      node({ id: "b", lat: 45, lng: null }),
      node({ id: "c", lat: 45, lng: -75 }),
    ]);
    expect(fc.features.map((f) => f.properties.id)).toEqual(["c"]);
  });

  it("keeps nodes at the 0/0 coordinate (0 is a valid coordinate)", () => {
    const fc = nodesToFeatureCollection([node({ id: "z", lat: 0, lng: 0 })]);
    expect(fc.features).toHaveLength(1);
    expect(fc.features[0]!.geometry.coordinates).toEqual([0, 0]);
  });

  it("passes decimal coordinates through untouched (api/nodes.go sends *float64 degrees)", () => {
    const fc = nodesToFeatureCollection([node({ lat: 49.28, lng: -123.12 })]);
    expect(fc.features[0]!.geometry.coordinates).toEqual([-123.12, 49.28]);
  });
});

describe("buildNeighborEdges", () => {
  const a = node({ id: "a", lat: 45, lng: -75, neighborIds: ["b", "c"] });
  const b = node({ id: "b", lat: 46, lng: -76, neighborIds: ["a"] });
  const c = node({ id: "c", lat: 47, lng: -77, neighborIds: ["a"] });

  it("emits one undirected edge per neighbor pair (a<->b counted once) in [lng, lat] order", () => {
    const fc = buildNeighborEdges([a, b], "on", null);
    expect(fc.type).toBe("FeatureCollection");
    expect(fc.features).toHaveLength(1);
    expect(fc.features[0]!.geometry).toEqual({
      type: "LineString",
      coordinates: [[-75, 45], [-76, 46]],
    });
  });

  it("skips neighbor ids absent from the set or without coordinates", () => {
    const lonely = node({ id: "a", lat: 45, lng: -75, neighborIds: ["ghost"] });
    const noCoord = node({ id: "b", lat: null, lng: null, neighborIds: ["a"] });
    expect(buildNeighborEdges([lonely, noCoord], "on", null).features).toEqual([]);
  });

  it("in 'selected' mode keeps only edges incident to the selected node", () => {
    const fc = buildNeighborEdges([a, b, c], "selected", "b");
    expect(fc.features).toHaveLength(1); // only a<->b
    expect(fc.features[0]!.properties.selected).toBe(true);
  });

  it("in 'on' mode emits all edges and flags those incident to the selected node", () => {
    const fc = buildNeighborEdges([a, b, c], "on", "b");
    expect(fc.features).toHaveLength(2); // a<->b and a<->c
    expect(fc.features.filter((f) => f.properties.selected)).toHaveLength(1); // a<->b
  });

  it("ignores a node that lists itself as a neighbor (no zero-length edge)", () => {
    const selfRef = node({ id: "a", lat: 45, lng: -75, neighborIds: ["a", "b"] });
    const fc = buildNeighborEdges([selfRef, b], "on", null);
    expect(fc.features).toHaveLength(1); // a<->b only, not a<->a
  });
});

describe("neighborFocusIds", () => {
  const a = node({ id: "a", lat: 45, lng: -75, neighborIds: ["b", "c"] });
  const b = node({ id: "b", lat: 46, lng: -76, neighborIds: ["a"] });
  const c = node({ id: "c", lat: 47, lng: -77, neighborIds: ["a"] });

  const sorted = (ids: string[] | null) => (ids ? [...ids].sort() : ids);

  it("returns null when nothing is selected", () => {
    expect(neighborFocusIds([a, b, c], null)).toBeNull();
  });

  it("includes the selected node and its located neighbors", () => {
    expect(sorted(neighborFocusIds([a, b, c], "a"))).toEqual(["a", "b", "c"]);
  });

  it("treats neighbor links as undirected (a node listing the selection counts)", () => {
    // b lists a; c does not list b, so only a<->b makes c irrelevant to b's focus set
    expect(sorted(neighborFocusIds([a, b, c], "b"))).toEqual(["a", "b"]);
  });

  it("skips neighbor ids that are absent or unlocated", () => {
    const noCoord = node({ id: "c", lat: null, lng: null, neighborIds: ["a"] });
    expect(sorted(neighborFocusIds([a, b, noCoord], "a"))).toEqual(["a", "b"]);
  });

  it("returns null when the selected node is unlocated (no marker to keep lit)", () => {
    const unlocated = node({ id: "a", lat: null, lng: null, neighborIds: ["b"] });
    expect(neighborFocusIds([unlocated, b], "a")).toBeNull();
  });

  it("returns null when the selected node has no located neighbors", () => {
    const lonely = node({ id: "x", lat: 45, lng: -75, neighborIds: ["ghost"] });
    expect(neighborFocusIds([lonely, b], "x")).toBeNull();
  });
});

describe("buildFocusedNeighborEdges", () => {
  const DAY = 86400000;
  const NOW = 1000 * DAY;
  const sel = node({ id: "a", lat: 45, lng: -75 });
  function nb(o: Partial<NodeNeighbor>): NodeNeighbor {
    return { id: "b", publicKey: "pk", nodeType: 2, nodeTypeName: "repeater", iata: "YOW", observationCount: 10, firstSeen: 0, lastSeen: NOW, lat: 46, lng: -76, ...o };
  }

  it("returns empty when nothing is selected", () => {
    expect(buildFocusedNeighborEdges(null, [nb({})], NOW).features).toEqual([]);
  });

  it("returns empty when the selected node has no coordinates", () => {
    expect(buildFocusedNeighborEdges(node({ id: "a", lat: null, lng: null }), [nb({})], NOW).features).toEqual([]);
  });

  it("draws one edge selected->neighbor with obs, in [lng, lat] order", () => {
    const fc = buildFocusedNeighborEdges(sel, [nb({ id: "b", lat: 46, lng: -76, observationCount: 42, lastSeen: NOW })], NOW);
    expect(fc.features).toHaveLength(1);
    expect(fc.features[0]!.geometry.coordinates).toEqual([[-75, 45], [-76, 46]]);
    expect(fc.features[0]!.properties.obs).toBe(42);
    expect(fc.features[0]!.properties.selected).toBe(true);
    expect(fc.features[0]!.properties.ageDays).toBe(0);
  });

  it("computes ageDays from lastSeen relative to now", () => {
    const fc = buildFocusedNeighborEdges(sel, [nb({ id: "b", lastSeen: NOW - 3 * DAY })], NOW);
    expect(fc.features[0]!.properties.ageDays).toBeCloseTo(3);
  });

  it("aggregates a neighbor's per-iata rows: sums obs, keeps the freshest lastSeen", () => {
    const fc = buildFocusedNeighborEdges(sel, [
      nb({ id: "b", iata: "YOW", observationCount: 30, lastSeen: NOW - 5 * DAY }),
      nb({ id: "b", iata: "YYZ", observationCount: 12, lastSeen: NOW - 1 * DAY }),
    ], NOW);
    expect(fc.features).toHaveLength(1);
    expect(fc.features[0]!.properties.obs).toBe(42);
    expect(fc.features[0]!.properties.ageDays).toBeCloseTo(1);
  });

  it("skips neighbors without coordinates and any self-referential row", () => {
    const fc = buildFocusedNeighborEdges(sel, [
      nb({ id: "b", lat: undefined, lng: undefined }),
      nb({ id: "a", lat: 45, lng: -75 }), // self — no zero-length edge
      nb({ id: "c", lat: 47, lng: -77, observationCount: 5 }),
    ], NOW);
    expect(fc.features.map((f) => f.properties.obs)).toEqual([5]);
  });
});

describe("filterByNodeType", () => {
  const fc = nodesToFeatureCollection([
    node({ id: "r1", nodeTypeName: "repeater" }),
    node({ id: "c1", nodeTypeName: "companion" }),
    node({ id: "r2", nodeTypeName: "repeater" }),
  ]);

  it("returns all features (same reference) for an empty type = All", () => {
    expect(filterByNodeType(fc, "")).toBe(fc);
  });

  it("keeps only features matching the given type", () => {
    expect(filterByNodeType(fc, "repeater").features.map((f) => f.properties.id)).toEqual(["r1", "r2"]);
  });

  it("returns an empty collection when nothing matches", () => {
    const out = filterByNodeType(fc, "sensor");
    expect(out.type).toBe("FeatureCollection");
    expect(out.features).toEqual([]);
  });
});
