const assert = require("node:assert/strict");
const test = require("node:test");

const {
  calculateBubbleRadius,
  calculateBubbleCoverage,
  calculateFocusFactor,
  calculateFocusedPercentage,
  calculateViewportFocusScale,
  calculateIdealBubbleDistance,
  calculateMembraneDisplacements,
  calculateMinimumCenterDistance,
  calculateRelationshipAttraction,
  clampBubbleValue,
  derivePackedBubbleLayout,
  deriveBubbleMapLayout,
  normalizePercentages,
  normalizeRelationshipWeights,
  relaxBubblePositions,
  resolveMembraneOverlaps,
  redistributeBubbleDelta,
  roundPercentagesToTotal
} = require("../js/wins-bubble-math.js");

function sumPercentages(items, key = "currentPercentage") {
  return items.reduce((sum, item) => sum + item[key], 0);
}

test("normalizePercentages returns active visible bubbles totaling 100", () => {
  const result = normalizePercentages([
    { id: "a", baseWeight: 2 },
    { id: "b", baseWeight: 1 },
    { id: "c", baseWeight: 1 }
  ]);

  assert.equal(Math.round(sumPercentages(result)), 100);
  assert.equal(result[0].currentPercentage, 50);
  assert.equal(result[1].currentPercentage, 25);
  assert.equal(result[2].currentPercentage, 25);
});

test("calculateBubbleRadius uses square-root area sizing and preserves mobile touch minimum", () => {
  assert.equal(calculateBubbleRadius(25, 24, 80), 64);
  assert.equal(calculateBubbleRadius(0, 12, 80), 24);
  assert.equal(calculateBubbleRadius(-4, 24, 80), 24);
});

test("calculateFocusFactor is distance based and clamped", () => {
  assert.equal(calculateFocusFactor(0, 500), 1);
  assert.equal(calculateFocusFactor(250, 500), 0.5);
  assert.equal(calculateFocusFactor(800, 500), 0);
});

test("focused percentage gains explicit focus boost", () => {
  assert.equal(calculateFocusedPercentage(20, 0.5, 10), 25);
  assert.equal(calculateFocusedPercentage(96, 1, 10, 100), 100);
});

test("viewport focus scale creates smooth multiple size levels", () => {
  const options = { focusRadius: 600, minScale: 0.98, maxScale: 1.86, curve: 1.28 };
  const center = calculateViewportFocusScale(0, options);
  const adjacent = calculateViewportFocusScale(120, options);
  const nearby = calculateViewportFocusScale(260, options);
  const far = calculateViewportFocusScale(620, options);

  assert.equal(Number(center.toFixed(2)), 1.86);
  assert.ok(adjacent < center && adjacent > 1.35);
  assert.ok(nearby < adjacent && nearby > 1.15);
  assert.ok(far < nearby && far >= 0.98);
  assert.ok(new Set([center, adjacent, nearby, far].map(value => value.toFixed(2))).size >= 4);
});

test("viewport focus scale clamps unsafe options and distance extremes", () => {
  assert.equal(calculateViewportFocusScale(-20, { minScale: 1, maxScale: 1.5 }), 1.5);
  assert.equal(calculateViewportFocusScale(2000, { minScale: 1, maxScale: 1.5 }), 1);
  assert.equal(calculateViewportFocusScale(0, { minScale: 2, maxScale: 1 }), 1);
});

test("weighted redistribution uses normalized relationship weights", () => {
  const result = redistributeBubbleDelta([
    { id: "a", currentPercentage: 40 },
    { id: "b", currentPercentage: 30 },
    { id: "c", currentPercentage: 20 },
    { id: "d", currentPercentage: 10 }
  ], "a", 10, { b: 5, c: 3, d: 2 });

  assert.equal(Math.round(sumPercentages(result)), 100);
  assert.equal(result.find(item => item.id === "a").currentPercentage, 50);
  assert.equal(result.find(item => item.id === "b").currentPercentage, 25);
  assert.equal(result.find(item => item.id === "c").currentPercentage, 17);
  assert.equal(result.find(item => item.id === "d").currentPercentage, 8);
});

test("proportional fallback redistribution works without relationship weights", () => {
  const result = redistributeBubbleDelta([
    { id: "a", currentPercentage: 40 },
    { id: "b", currentPercentage: 30 },
    { id: "c", currentPercentage: 20 },
    { id: "d", currentPercentage: 10 }
  ], "a", 10);

  assert.equal(Math.round(sumPercentages(result)), 100);
  assert.equal(result.find(item => item.id === "a").currentPercentage, 50);
  assert.equal(Number(result.find(item => item.id === "b").currentPercentage.toFixed(2)), 25);
  assert.equal(Number(result.find(item => item.id === "c").currentPercentage.toFixed(2)), 16.67);
  assert.equal(Number(result.find(item => item.id === "d").currentPercentage.toFixed(2)), 8.33);
});

test("redistribution respects min and max constraints and prevents negatives", () => {
  const result = redistributeBubbleDelta([
    { id: "a", currentPercentage: 45, maxPercentage: 50 },
    { id: "b", currentPercentage: 5, minPercentage: 4 },
    { id: "c", currentPercentage: 50, minPercentage: 0 }
  ], "a", 20);

  assert.equal(Math.round(sumPercentages(result)), 100);
  assert.equal(result.find(item => item.id === "a").currentPercentage, 50);
  assert.ok(result.every(item => item.currentPercentage >= 0));
  assert.ok(result.find(item => item.id === "b").currentPercentage >= 4);
});

test("relationship weights normalize safely", () => {
  assert.deepEqual(normalizeRelationshipWeights({ b: 5, c: 5 }), { b: 0.5, c: 0.5 });
  assert.deepEqual(normalizeRelationshipWeights({ b: -1, c: 0 }), {});
});

test("roundPercentagesToTotal creates display integers totaling 100", () => {
  const rounded = roundPercentagesToTotal([
    { id: "a", currentPercentage: 33.3 },
    { id: "b", currentPercentage: 33.3 },
    { id: "c", currentPercentage: 33.4 }
  ]);

  assert.equal(sumPercentages(rounded, "roundedPercentage"), 100);
  assert.deepEqual(rounded.map(item => item.roundedPercentage).sort((a, b) => a - b), [33, 33, 34]);
});

test("deriveBubbleMapLayout returns focus, radius, distance, and normalized percentages", () => {
  const result = deriveBubbleMapLayout([
    { id: "near", baseWeight: 1, x: 100, y: 100 },
    { id: "far", baseWeight: 1, x: 600, y: 600 }
  ], { width: 200, height: 200 }, { x: 0, y: 0 }, { focusRadius: 500, focusBoost: 12 });

  assert.equal(Math.round(sumPercentages(result)), 100);
  assert.equal(result.find(item => item.id === "near").isFocused, true);
  assert.equal(result.find(item => item.id === "near").distanceFromViewportCenter, 0);
  assert.ok(result.find(item => item.id === "near").radius >= 24);
  assert.ok(result.find(item => item.id === "near").currentPercentage > result.find(item => item.id === "far").currentPercentage);
});

test("clampBubbleValue handles unsafe values", () => {
  assert.equal(clampBubbleValue("nope", 2, 8), 2);
  assert.equal(clampBubbleValue(20, 2, 8), 8);
  assert.equal(clampBubbleValue(-4, 2, 8), 2);
});

test("calculateBubbleCoverage estimates visible fill ratio", () => {
  const coverage = calculateBubbleCoverage([
    { radius: 50 },
    { radius: 50 }
  ], { width: 200, height: 200 });

  assert.ok(coverage > 0.39);
  assert.ok(coverage < 0.4);
});

test("ideal distance tightens for related or same-category bubbles", () => {
  const unrelated = calculateIdealBubbleDistance(
    { id: "a", category: "Movement", radius: 40 },
    { id: "b", category: "Protein Win", radius: 40 },
    { desiredGap: 8, relationshipTightening: 20 }
  );
  const related = calculateIdealBubbleDistance(
    { id: "a", category: "Movement", radius: 40, relationshipWeights: { b: 1 } },
    { id: "b", category: "Protein Win", radius: 40 },
    { desiredGap: 8, relationshipTightening: 20 }
  );
  const sameCategory = calculateRelationshipAttraction(
    { id: "a", category: "Movement" },
    { id: "b", category: "Movement" },
    { sameCategoryAttraction: 0.25 }
  );

  assert.equal(unrelated, 88);
  assert.equal(related, 80);
  assert.equal(sameCategory, 0.25);
});

test("minimum center distance preserves bubble ownership space", () => {
  const minimum = calculateMinimumCenterDistance(
    { radius: 44 },
    { radius: 52 },
    { minGap: 3 }
  );

  assert.equal(minimum, 99);
});

test("relaxBubblePositions separates collisions deterministically", () => {
  const input = [
    { id: "a", category: "Movement", x: 100, y: 100, anchorX: 100, anchorY: 100, radius: 50 },
    { id: "b", category: "Movement", x: 112, y: 100, anchorX: 112, anchorY: 100, radius: 50 },
    { id: "c", category: "Protein Win", x: 260, y: 100, anchorX: 260, anchorY: 100, radius: 50 }
  ];
  const first = relaxBubblePositions(input, {
    iterations: 10,
    desiredGap: 0,
    world: { minX: 0, minY: 0, maxX: 400, maxY: 260 },
    categoryCenters: { Movement: { x: 110, y: 100 }, "Protein Win": { x: 260, y: 100 } },
    cropAllowance: 0
  });
  const second = relaxBubblePositions(input, {
    iterations: 10,
    desiredGap: 0,
    world: { minX: 0, minY: 0, maxX: 400, maxY: 260 },
    categoryCenters: { Movement: { x: 110, y: 100 }, "Protein Win": { x: 260, y: 100 } },
    cropAllowance: 0
  });
  const distance = Math.hypot(first[0].x - first[1].x, first[0].y - first[1].y);

  assert.deepEqual(first.map(item => [Number(item.x.toFixed(2)), Number(item.y.toFixed(2))]), second.map(item => [Number(item.x.toFixed(2)), Number(item.y.toFixed(2))]));
  assert.ok(distance > 80);
  assert.ok(distance > Math.hypot(input[0].x - input[1].x, input[0].y - input[1].y));
});

test("resolveMembraneOverlaps moves dense pairs to at least the minimum gap", () => {
  const resolved = resolveMembraneOverlaps([
    { id: "a", x: 100, y: 100, radius: 50 },
    { id: "b", x: 125, y: 100, radius: 50 },
    { id: "c", x: 240, y: 100, radius: 45 }
  ], {
    minGap: 2,
    overlapIterations: 18,
    overlapSeparationStrength: 0.9,
    world: { minX: 0, minY: 0, maxX: 420, maxY: 260 },
    cropAllowance: 0
  });
  const a = resolved.find(item => item.id === "a");
  const b = resolved.find(item => item.id === "b");
  const distance = Math.hypot(a.x - b.x, a.y - b.y);

  assert.ok(distance >= 101.5);
});

test("edge and cropped nodes participate in global non-overlap resolution", () => {
  const resolved = resolveMembraneOverlaps([
    { id: "edge", x: 52, y: 100, radius: 54 },
    { id: "middle", x: 95, y: 100, radius: 52 },
    { id: "next", x: 150, y: 100, radius: 50 }
  ], {
    minGap: 2,
    overlapIterations: 42,
    overlapSeparationStrength: 0.94,
    overlapTolerance: 0.1,
    world: { minX: 0, minY: 0, maxX: 360, maxY: 260 },
    cropAllowance: 72
  });

  for (let i = 0; i < resolved.length; i += 1) {
    for (let j = i + 1; j < resolved.length; j += 1) {
      const distance = Math.hypot(resolved[i].x - resolved[j].x, resolved[i].y - resolved[j].y);
      const minimum = calculateMinimumCenterDistance(resolved[i], resolved[j], { minGap: 2 });
      assert.ok(distance >= minimum - 1.5);
    }
  }
});

test("derivePackedBubbleLayout increases sparse mobile coverage and preserves minimum radius", () => {
  const input = Array.from({ length: 8 }, (_, index) => ({
    id: `b${index}`,
    category: index < 4 ? "Movement" : "Protein Win",
    baseWeight: 1,
    x: 80 + index * 90,
    y: 120 + (index % 2) * 90,
    anchorX: 80 + index * 90,
    anchorY: 120 + (index % 2) * 90
  }));
  const packed = derivePackedBubbleLayout(input, { width: 390, height: 720 }, {
    targetFillRatio: 0.75,
    minRadius: 34,
    sizeRange: 96,
    desiredGap: 2,
    minGap: 1,
    iterations: 12,
    overlapIterations: 12,
    world: { minX: 0, minY: 0, maxX: 780, maxY: 900 },
    cropAllowance: 20,
    categoryCenters: {
      Movement: { x: 160, y: 240 },
      "Protein Win": { x: 540, y: 420 }
    }
  });

  assert.equal(Math.round(sumPercentages(packed)), 100);
  assert.ok(packed.every(item => item.radius >= 34));
  assert.ok(packed[0].coverageEstimate >= 0.7);
});

test("dense packed layout keeps neighboring cells adjacent without body overlap", () => {
  const input = Array.from({ length: 9 }, (_, index) => ({
    id: `cell-${index}`,
    category: index % 2 ? "Movement" : "Protein Win",
    baseWeight: 1,
    x: 120 + (index % 3) * 28,
    y: 140 + Math.floor(index / 3) * 28,
    anchorX: 120 + (index % 3) * 28,
    anchorY: 140 + Math.floor(index / 3) * 28
  }));
  const packed = derivePackedBubbleLayout(input, { width: 390, height: 720 }, {
    minRadius: 34,
    sizeRange: 62,
    targetFillRatio: 0.7,
    desiredGap: 2,
    minGap: 1,
    iterations: 18,
    overlapIterations: 18,
    world: { minX: 0, minY: 0, maxX: 520, maxY: 620 },
    cropAllowance: 36,
    categoryCenters: {
      Movement: { x: 220, y: 240 },
      "Protein Win": { x: 270, y: 285 }
    }
  });

  for (let i = 0; i < packed.length; i += 1) {
    for (let j = i + 1; j < packed.length; j += 1) {
      const distance = Math.hypot(packed[i].x - packed[j].x, packed[i].y - packed[j].y);
      const minimum = calculateMinimumCenterDistance(packed[i], packed[j], { minGap: 1 });
      assert.ok(distance >= minimum - 2.5);
    }
  }
});

test("membrane displacement cascades focused growth through neighboring cells", () => {
  const bubbles = [
    { id: "focus", x: 100, y: 100, radius: 48, scale: 1.42 },
    { id: "near", x: 192, y: 100, radius: 44, scale: 1 },
    { id: "next", x: 278, y: 100, radius: 42, scale: 1 },
    { id: "far", x: 410, y: 100, radius: 40, scale: 1 }
  ];
  const displacements = calculateMembraneDisplacements(bubbles, "focus", {
    iterations: 16,
    minGap: 3,
    strength: 0.82,
    maxDisplacement: 70
  });

  assert.equal(displacements.focus.x, 0);
  assert.ok(displacements.near.x > 12);
  assert.ok(displacements.next.x > 1);
  assert.ok(Math.abs(displacements.far.x) < Math.abs(displacements.near.x));

  const moved = bubbles.map(item => ({
    ...item,
    x: item.x + displacements[item.id].x,
    y: item.y + displacements[item.id].y
  }));
  for (let i = 0; i < moved.length; i += 1) {
    for (let j = i + 1; j < moved.length; j += 1) {
      const distance = Math.hypot(moved[i].x - moved[j].x, moved[i].y - moved[j].y);
      const minimum = moved[i].radius * moved[i].scale + moved[j].radius * moved[j].scale + 3;
      assert.ok(distance >= minimum - 4);
    }
  }
});

test("membrane displacement is deterministic and bounded", () => {
  const bubbles = [
    { id: "a", x: 100, y: 100, radius: 60, scale: 1.3 },
    { id: "b", x: 150, y: 100, radius: 58, scale: 1 },
    { id: "c", x: 206, y: 100, radius: 58, scale: 1 }
  ];
  const options = { iterations: 12, maxDisplacement: 24, minGap: 2, strength: 1 };
  const first = calculateMembraneDisplacements(bubbles, "a", options);
  const second = calculateMembraneDisplacements(bubbles, "a", options);

  assert.deepEqual(first, second);
  assert.ok(Object.values(first).every(item => Math.abs(item.x) <= 24 && Math.abs(item.y) <= 24));
});

test("focused membrane displacement preserves visual gap for larger peripheral bubbles", () => {
  const bubbles = [
    { id: "focus", x: 180, y: 220, radius: 82, scale: 1.32 },
    { id: "upper-edge", x: 174, y: 82, radius: 68, scale: 1 },
    { id: "left-edge", x: 58, y: 222, radius: 66, scale: 1 },
    { id: "right", x: 306, y: 220, radius: 67, scale: 1 },
    { id: "lower", x: 184, y: 354, radius: 70, scale: 1 },
    { id: "lower-right", x: 296, y: 342, radius: 64, scale: 1 }
  ];
  const options = {
    iterations: 38,
    minGap: 7,
    strength: 0.92,
    maxDisplacement: 118,
    pinFocused: true
  };
  const displacements = calculateMembraneDisplacements(bubbles, "focus", options);
  const moved = bubbles.map(item => ({
    ...item,
    x: item.x + displacements[item.id].x,
    y: item.y + displacements[item.id].y
  }));

  assert.equal(displacements.focus.x, 0);
  assert.equal(displacements.focus.y, 0);
  assert.ok(Math.abs(displacements["upper-edge"].y) > 10);
  assert.ok(Math.abs(displacements["left-edge"].x) > 10);

  for (let i = 0; i < moved.length; i += 1) {
    for (let j = i + 1; j < moved.length; j += 1) {
      const distance = Math.hypot(moved[i].x - moved[j].x, moved[i].y - moved[j].y);
      const minimum = moved[i].radius * moved[i].scale + moved[j].radius * moved[j].scale + options.minGap;
      assert.ok(distance >= minimum - 6);
    }
  }
});

test("membrane collision uses final focus-gradient scaled radius", () => {
  const scaleOptions = { focusRadius: 600, minScale: 0.98, maxScale: 1.58, curve: 1.28 };
  const bubbles = [
    { id: "center", x: 200, y: 200, radius: 70, scale: calculateViewportFocusScale(0, { ...scaleOptions, maxScale: 1.86 }) },
    { id: "adjacent", x: 330, y: 200, radius: 62, scale: calculateViewportFocusScale(120, scaleOptions) },
    { id: "nearby", x: 420, y: 205, radius: 56, scale: calculateViewportFocusScale(240, scaleOptions) },
    { id: "far", x: 570, y: 220, radius: 54, scale: calculateViewportFocusScale(430, scaleOptions) }
  ];
  const minGap = 6;
  const displacements = calculateMembraneDisplacements(bubbles, "center", {
    iterations: 42,
    minGap,
    strength: 0.92,
    maxDisplacement: 140,
    pinFocused: true
  });
  const moved = bubbles.map(item => ({
    ...item,
    x: item.x + displacements[item.id].x,
    y: item.y + displacements[item.id].y
  }));

  assert.deepEqual(displacements.center, { x: 0, y: 0 });
  for (let i = 0; i < moved.length; i += 1) {
    for (let j = i + 1; j < moved.length; j += 1) {
      const distance = Math.hypot(moved[i].x - moved[j].x, moved[i].y - moved[j].y);
      const minimum = moved[i].radius * moved[i].scale + moved[j].radius * moved[j].scale + minGap;
      assert.ok(distance >= minimum - 1.2, `${moved[i].id}/${moved[j].id} distance ${distance} should respect ${minimum}`);
    }
  }
});

test("related bubbles end closer than unrelated bubbles where possible", () => {
  const packed = derivePackedBubbleLayout([
    { id: "a", category: "Movement", baseWeight: 1, x: 80, y: 100, anchorX: 80, anchorY: 100, relationshipWeights: { b: 1 } },
    { id: "b", category: "Protein Win", baseWeight: 1, x: 420, y: 110, anchorX: 420, anchorY: 110 },
    { id: "c", category: "Non-Scale Victory", baseWeight: 1, x: 500, y: 420, anchorX: 500, anchorY: 420 }
  ], { width: 390, height: 720 }, {
    minRadius: 34,
    sizeRange: 84,
    iterations: 20,
    attractionStrength: 0.05,
    attractionRange: 500,
    relationshipTightening: 24,
    world: { minX: 0, minY: 0, maxX: 700, maxY: 700 },
    categoryCenters: {
      Movement: { x: 80, y: 100 },
      "Protein Win": { x: 420, y: 110 },
      "Non-Scale Victory": { x: 500, y: 420 }
    }
  });
  const a = packed.find(item => item.id === "a");
  const b = packed.find(item => item.id === "b");
  const c = packed.find(item => item.id === "c");
  const relatedDistance = Math.hypot(a.x - b.x, a.y - b.y);
  const unrelatedDistance = Math.hypot(a.x - c.x, a.y - c.y);

  assert.ok(relatedDistance < unrelatedDistance);
});
