const FIREWORK_COLORS = [
  "#e42b2b",
  "#22c55e",
  "#fffb23",
  "#f59e0b",
  "#3b82f6",
  "#ec8f48",
];

const NARROW_COLUMNS = 28;
const WIDE_COLUMNS = 14;
const WIDE_QUERY = "(min-width: 768px)";
const DARK_QUERY = "(prefers-color-scheme: dark)";
const REDUCED_MOTION_QUERY = "(prefers-reduced-motion: reduce)";
const BRIGHTNESS_STEPS = 64;
const ENERGY_DECAY_PER_SECOND = 0.03;
const MIN_LAUNCH_DELAY_MS = 500;
const MAX_LAUNCH_DELAY_MS = 2000;
const MAX_ACTIVE_ROCKETS = 3;
const TRAIL_RADIUS = 0.6;
const TRAIL_ENERGY = 0.8;
const EXPLOSION_ENERGY = 1.3;
const MIN_EXPLOSION_RADIUS = 5;
const EXPLOSION_RADIUS_VARIANCE = 3.5;
const MIN_EXPLOSION_DURATION = 1.2;
const EXPLOSION_DURATION_VARIANCE = 0.6;
const MIN_ROCKET_SPEED = 8;
const ROCKET_SPEED_VARIANCE = 4;
const TARGET_HEIGHT_FRACTION = 0.35;
const VISIBLE_ENERGY_THRESHOLD = 0.05;

const BASE_DOT_CLASS =
  "aspect-square rounded-full transition-all duration-300 bg-gray-200 dark:bg-gray-700 size-[4px] max-w-full max-h-full";

const MASTER_VOLUME = 0.18;
const LAUNCH_SOUND_DURATION = 0.35;
const LAUNCH_START_FREQUENCY = 140;
const LAUNCH_END_FREQUENCY = 820;
const EXPLOSION_NOISE_DURATION = 2.0;
const EXPLOSION_POP_DURATION = 1.1;
const EXPLOSION_RUMBLE_DURATION = 1.8;
const EXPLOSION_CRACKLE_RATE = 28;

let sharedAudioContext = null;

const getAudioContext = () => {
  const AudioContextClass = window.AudioContext || window.webkitAudioContext;
  if (!AudioContextClass) return null;
  if (!sharedAudioContext) sharedAudioContext = new AudioContextClass();
  if (sharedAudioContext.state === "suspended") sharedAudioContext.resume();
  return sharedAudioContext;
};

// Browsers block audio until a user gesture happens. We create the
// AudioContext right away (it starts "suspended" if there's been no
// gesture yet) and then resume it on every subsequent interaction, since
// we can't know in advance which gesture the browser will accept.
const GESTURE_EVENTS = ["pointerdown", "keydown", "touchstart", "click"];

const unlockAudioOnFirstGesture = () => {
  getAudioContext();
  const resume = () => getAudioContext();
  GESTURE_EVENTS.forEach((eventName) => {
    window.addEventListener(eventName, resume, { passive: true });
  });
};

// Retro 8-bit "launch" blip: a square wave that sweeps upward in pitch,
// like a rocket taking off in an 80s arcade game.
const playLaunchSound = () => {
  const ctx = getAudioContext();
  if (!ctx) return;
  const now = ctx.currentTime;
  const pitchWobble = 0.85 + Math.random() * 0.3;

  const oscillator = ctx.createOscillator();
  oscillator.type = "square";
  oscillator.frequency.setValueAtTime(
    LAUNCH_START_FREQUENCY * pitchWobble,
    now,
  );
  oscillator.frequency.exponentialRampToValueAtTime(
    LAUNCH_END_FREQUENCY * pitchWobble,
    now + LAUNCH_SOUND_DURATION,
  );

  const gain = ctx.createGain();
  gain.gain.setValueAtTime(0.0001, now);
  gain.gain.exponentialRampToValueAtTime(MASTER_VOLUME, now + 0.02);
  gain.gain.exponentialRampToValueAtTime(0.0001, now + LAUNCH_SOUND_DURATION);

  oscillator.connect(gain).connect(ctx.destination);
  oscillator.start(now);
  oscillator.stop(now + LAUNCH_SOUND_DURATION + 0.05);
};

// Retro 8-bit "explosion" blast: a drawn-out, crackly noise crash with a
// descending rumble tail, closer to the classic Duck Hunt gunshot/explosion
// than a quick arcade pop.
const playExplosionSound = () => {
  const ctx = getAudioContext();
  if (!ctx) return;
  const now = ctx.currentTime;

  // Main noise crash: white noise shaped with a fast attack and a long
  // decay. The crackle is baked directly into the buffer as a slow random
  // flutter multiplied over the decay curve, so it doesn't rely on
  // real-time gain modulation (which can misbehave across browsers).
  const bufferLength = Math.floor(ctx.sampleRate * EXPLOSION_NOISE_DURATION);
  const noiseBuffer = ctx.createBuffer(1, bufferLength, ctx.sampleRate);
  const noiseData = noiseBuffer.getChannelData(0);
  const crackleStep = Math.max(
    1,
    Math.floor(ctx.sampleRate / EXPLOSION_CRACKLE_RATE),
  );
  let crackleFlutter = 1;
  for (let i = 0; i < bufferLength; i++) {
    if (i % crackleStep === 0) crackleFlutter = 0.55 + Math.random() * 0.45;
    const t = i / bufferLength;
    const decay = Math.pow(1 - t, 1.6);
    noiseData[i] = (Math.random() * 2 - 1) * decay * crackleFlutter;
  }

  const noiseSource = ctx.createBufferSource();
  noiseSource.buffer = noiseBuffer;

  const noiseFilter = ctx.createBiquadFilter();
  noiseFilter.type = "lowpass";
  noiseFilter.Q.value = 0.8;
  noiseFilter.frequency.setValueAtTime(2600, now);
  noiseFilter.frequency.exponentialRampToValueAtTime(
    120,
    now + EXPLOSION_NOISE_DURATION,
  );

  const noiseGain = ctx.createGain();
  noiseGain.gain.setValueAtTime(MASTER_VOLUME * 1.5, now);

  noiseSource.connect(noiseFilter).connect(noiseGain).connect(ctx.destination);
  noiseSource.start(now);
  noiseSource.stop(now + EXPLOSION_NOISE_DURATION);

  // Sharp initial pop for the moment of impact.
  const popOscillator = ctx.createOscillator();
  popOscillator.type = "square";
  const popPitchWobble = 0.8 + Math.random() * 0.4;
  popOscillator.frequency.setValueAtTime(420 * popPitchWobble, now);
  popOscillator.frequency.exponentialRampToValueAtTime(
    60 * popPitchWobble,
    now + EXPLOSION_POP_DURATION,
  );

  const popGain = ctx.createGain();
  popGain.gain.setValueAtTime(MASTER_VOLUME, now);
  popGain.gain.exponentialRampToValueAtTime(
    0.0001,
    now + EXPLOSION_POP_DURATION,
  );

  popOscillator.connect(popGain).connect(ctx.destination);
  popOscillator.start(now);
  popOscillator.stop(now + EXPLOSION_POP_DURATION + 0.05);

  // Low rumble tail that lingers underneath, giving the explosion weight
  // and length rather than cutting off abruptly.
  const rumbleOscillator = ctx.createOscillator();
  rumbleOscillator.type = "sine";
  rumbleOscillator.frequency.setValueAtTime(90, now);
  rumbleOscillator.frequency.exponentialRampToValueAtTime(
    35,
    now + EXPLOSION_RUMBLE_DURATION,
  );

  const rumbleGain = ctx.createGain();
  rumbleGain.gain.setValueAtTime(0.0001, now);
  rumbleGain.gain.exponentialRampToValueAtTime(MASTER_VOLUME * 0.9, now + 0.05);
  rumbleGain.gain.exponentialRampToValueAtTime(
    0.0001,
    now + EXPLOSION_RUMBLE_DURATION,
  );

  rumbleOscillator.connect(rumbleGain).connect(ctx.destination);
  rumbleOscillator.start(now);
  rumbleOscillator.stop(now + EXPLOSION_RUMBLE_DURATION + 0.05);
};

const readGrid = (punchcard, columnCount) => {
  const dots = Array.from(
    punchcard.children,
    (wrapper) => wrapper.firstElementChild,
  ).filter(Boolean);

  dots.forEach((dot) => {
    // Overwrite the server-rendered classes (which color dots based on
    // commit counts) with a neutral base class, so only the animation
    // controls what color shows up on the grid.
    dot.className = BASE_DOT_CLASS;
    dot.style.transition = "none";
    dot.style.transformOrigin = "center";
    dot.style.willChange = "background-color, transform";
    dot.style.backgroundColor = "";
    dot.style.transform = "";
    dot.style.border = "";
  });

  return {
    dots,
    columns: columnCount,
    rows: Math.max(1, Math.ceil(dots.length / columnCount)),
  };
};

const resetDot = (dot) => {
  dot.style.backgroundColor = "";
  dot.style.transform = "";
  dot.style.border = "";
};

const createEnergyBuffer = (size) => ({
  energy: new Float32Array(size),
  color: new Array(size).fill(null),
});

const startFireworks = (punchcard) => {
  const abortController = new AbortController();
  const listenerOptions = { signal: abortController.signal };
  const wideQuery = matchMedia(WIDE_QUERY);
  const darkQuery = matchMedia(DARK_QUERY);
  const reducedMotion = matchMedia(REDUCED_MOTION_QUERY).matches;
  const columnsForViewport = () =>
    wideQuery.matches ? WIDE_COLUMNS : NARROW_COLUMNS;

  punchcard.style.userSelect = "none";
  punchcard.style.WebkitUserSelect = "none";

  let grid = readGrid(punchcard, columnsForViewport());
  let isDarkMode = darkQuery.matches;
  let baseColor = isDarkMode ? "rgb(55 65 81)" : "rgb(229 231 235)";

  let currentBuffer = createEnergyBuffer(grid.dots.length);
  let nextBuffer = createEnergyBuffer(grid.dots.length);
  let renderedBrightness = new Float32Array(grid.dots.length).fill(-1);
  let renderedColor = new Array(grid.dots.length).fill(null);

  let rockets = [];
  let explosions = [];
  let nextLaunchTime = 0;
  let lastFrameMs = 0;
  let animationFrameId = 0;
  let isVisible = true;

  const rebuildGrid = () => {
    grid = readGrid(punchcard, columnsForViewport());
    currentBuffer = createEnergyBuffer(grid.dots.length);
    nextBuffer = createEnergyBuffer(grid.dots.length);
    renderedBrightness = new Float32Array(grid.dots.length).fill(-1);
    renderedColor = new Array(grid.dots.length).fill(null);
    rockets = [];
    explosions = [];
  };

  const updateColorScheme = () => {
    isDarkMode = darkQuery.matches;
    baseColor = isDarkMode ? "rgb(55 65 81)" : "rgb(229 231 235)";
  };

  wideQuery.addEventListener("change", rebuildGrid, listenerOptions);
  darkQuery.addEventListener("change", updateColorScheme, listenerOptions);

  const depositEnergy = (buffer, column, row, color, energy) => {
    if (column < 0 || column >= grid.columns || row < 0 || row >= grid.rows)
      return;
    const index = row * grid.columns + column;
    if (
      index >= 0 &&
      index < buffer.energy.length &&
      energy > buffer.energy[index]
    ) {
      buffer.energy[index] = energy;
      buffer.color[index] = color;
    }
  };

  const depositCircle = (
    buffer,
    centerColumn,
    centerRow,
    color,
    energy,
    radius,
  ) => {
    const baseColumn = Math.round(centerColumn);
    const baseRow = Math.round(centerRow);
    const cells = Math.ceil(radius);
    for (let dx = -cells; dx <= cells; dx++) {
      for (let dy = -cells; dy <= cells; dy++) {
        const column = baseColumn + dx;
        const row = baseRow + dy;
        const distance = Math.hypot(column - centerColumn, row - centerRow);
        if (distance > radius) continue;
        depositEnergy(
          buffer,
          column,
          row,
          color,
          energy * (1 - distance / radius),
        );
      }
    }
  };

  const launchRocket = (time) => {
    if (rockets.length >= MAX_ACTIVE_ROCKETS) return;
    const column = Math.floor(Math.random() * grid.columns);
    const targetRow = Math.floor(
      Math.random() * (grid.rows * TARGET_HEIGHT_FRACTION),
    );
    const color =
      FIREWORK_COLORS[Math.floor(Math.random() * FIREWORK_COLORS.length)];
    rockets.push({
      column,
      row: grid.rows - 1,
      targetRow,
      color,
      startTime: time,
      speed: MIN_ROCKET_SPEED + Math.random() * ROCKET_SPEED_VARIANCE,
    });
    playLaunchSound();
  };

  const spawnExplosion = (time, rocket) => {
    explosions.push({
      column: rocket.column,
      row: rocket.row,
      color: rocket.color,
      startTime: time,
      radius: 0,
      maxRadius:
        MIN_EXPLOSION_RADIUS + Math.random() * EXPLOSION_RADIUS_VARIANCE,
      duration:
        MIN_EXPLOSION_DURATION + Math.random() * EXPLOSION_DURATION_VARIANCE,
    });
    playExplosionSound();
  };

  const advanceRockets = (time) => {
    rockets = rockets.filter((rocket) => {
      const elapsed = time - rocket.startTime;
      rocket.row = grid.rows - 1 - elapsed * rocket.speed;

      if (rocket.row <= rocket.targetRow) {
        spawnExplosion(time, rocket);
        return false;
      }

      depositCircle(
        nextBuffer,
        rocket.column,
        rocket.row,
        rocket.color,
        TRAIL_ENERGY,
        TRAIL_RADIUS,
      );
      return true;
    });
  };

  const advanceExplosions = (time) => {
    explosions = explosions.filter((explosion) => {
      const progress = (time - explosion.startTime) / explosion.duration;
      if (progress >= 1) return false;

      explosion.radius = explosion.maxRadius * progress;
      const intensity = 1 - progress;
      depositCircle(
        nextBuffer,
        explosion.column,
        explosion.row,
        explosion.color,
        intensity * EXPLOSION_ENERGY,
        explosion.radius,
      );
      return true;
    });
  };

  const renderDots = () => {
    grid.dots.forEach((dot, index) => {
      const energy = nextBuffer.energy[index];
      if (energy >= VISIBLE_ENERGY_THRESHOLD) {
        const brightness = Math.round(Math.min(1, energy) * BRIGHTNESS_STEPS);
        const color = nextBuffer.color[index];
        if (
          brightness !== renderedBrightness[index] ||
          color !== renderedColor[index]
        ) {
          const brightnessFraction = brightness / BRIGHTNESS_STEPS;
          const mixPercent = Math.min(100, brightnessFraction * 170).toFixed(1);
          dot.style.backgroundColor = `color-mix(in srgb, ${color} ${mixPercent}%, ${baseColor})`;
          dot.style.transform = `scale(${(1 + brightnessFraction * 0.8).toFixed(3)})`;
          dot.style.border = "0";
          renderedBrightness[index] = brightness;
          renderedColor[index] = color;
        }
      } else if (renderedBrightness[index] !== -1) {
        resetDot(dot);
        renderedBrightness[index] = -1;
        renderedColor[index] = null;
      }
    });
  };

  const tick = (nowMs) => {
    const time = nowMs / 1000;
    const deltaSeconds = lastFrameMs
      ? Math.min(0.1, (nowMs - lastFrameMs) / 1000)
      : 0;
    lastFrameMs = nowMs;
    const decayFactor = Math.pow(ENERGY_DECAY_PER_SECOND, deltaSeconds * 60);

    if (time >= nextLaunchTime) {
      launchRocket(time);
      nextLaunchTime =
        time +
        (MIN_LAUNCH_DELAY_MS +
          Math.random() * (MAX_LAUNCH_DELAY_MS - MIN_LAUNCH_DELAY_MS)) /
          1000;
    }

    // Decay the previous frame's energy into the next buffer first,
    // then let rockets/explosions add fresh energy on top of it.
    currentBuffer.energy.forEach((energy, index) => {
      nextBuffer.energy[index] = energy * decayFactor;
      nextBuffer.color[index] = currentBuffer.color[index];
    });

    advanceRockets(time);
    advanceExplosions(time);
    renderDots();

    const swap = currentBuffer;
    currentBuffer = nextBuffer;
    nextBuffer = swap;

    if (!reducedMotion && isVisible)
      animationFrameId = requestAnimationFrame(tick);
  };

  const visibilityObserver = new IntersectionObserver((entries) => {
    const nowVisible = entries.some((entry) => entry.isIntersecting);
    if (nowVisible === isVisible) return;
    isVisible = nowVisible;
    if (isVisible) {
      lastFrameMs = 0;
      animationFrameId = requestAnimationFrame(tick);
    } else {
      cancelAnimationFrame(animationFrameId);
    }
  });
  visibilityObserver.observe(punchcard);

  unlockAudioOnFirstGesture();

  if (!reducedMotion) animationFrameId = requestAnimationFrame(tick);

  return () => {
    cancelAnimationFrame(animationFrameId);
    visibilityObserver.disconnect();
    abortController.abort();
  };
};

const punchcard = document.querySelector("[data-punchcard]");
if (punchcard) {
  startFireworks(punchcard);
}
