"use client";

import { useCallback, useEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
import Matter from "matter-js";
import { Avatar } from "@/components/avatar";
import {
  buildTierRadii,
  CONTAINER_HEIGHT,
  CONTAINER_WIDTH,
  DANGER_LINE_Y,
  DROP_COOLDOWN_MS,
  DROP_MAX_TIER_CAP,
  DROP_MIN_TIER,
  FIXED_DT_MS,
  GAME_OVER_GRACE_MS,
  MAX_FRAME_DT_MS,
  WALL_THICKNESS,
} from "@/lib/melon/constants";
import { playMergeSound } from "@/lib/melon/sound";
import { Confetti } from "@/components/confetti";
import type { MemberRow } from "./page";

type Status = "playing" | "dead";

type Piece = { id: number; tier: number };

type PendingMerge = { pieceIdA: number; pieceIdB: number; tier: number; x: number; y: number };

type BodyMeta = { pieceId: number; tier: number };

function randomTier(min: number, max: number): number {
  return min + Math.floor(Math.random() * (max - min + 1));
}

export type MelonRunTelemetry = {
  v: 1;
  duration_ms: number;
  actions: number;
  merges: number;
};

export function MelonGame({
  citizens,
  onStart,
  onFinish,
}: {
  citizens: MemberRow[];
  onStart: () => void;
  onFinish: (score: number, run: MelonRunTelemetry) => void;
}) {
  const containerRef = useRef<HTMLDivElement | null>(null);
  const previewRef = useRef<HTMLDivElement | null>(null);
  const guideLineRef = useRef<HTMLDivElement | null>(null);
  const nodeRefs = useRef(new Map<number, HTMLDivElement>());

  // Un palier de taille par Citoyen, pas un nombre arbitraire — voir
  // buildTierRadii. citizens[tier] (indexation directe, sans tirage) donne
  // l'association palier → Citoyen : fixe pour tout le monde, pas juste
  // pour une partie — corne/page.tsx trie déjà les Citoyens par pseudo, ce
  // qui donne un ordre déterministe et stable sans dépendre d'un choix
  // arbitraire de personnes précises.
  const tierRadii = useMemo(() => buildTierRadii(citizens.length), [citizens.length]);
  const maxTier = tierRadii.length - 1;
  const dropMaxTier = Math.min(DROP_MAX_TIER_CAP, maxTier);

  const [status, setStatus] = useState<Status>("playing");
  const [score, setScore] = useState(0);
  const [pieces, setPieces] = useState<Piece[]>([]);
  const [nextTier, setNextTier] = useState(() => randomTier(DROP_MIN_TIER, dropMaxTier));
  // Palier annoncé après nextTier — juste informatif (pas de position à
  // viser), pour voir venir deux coups à l'avance au lieu d'un seul.
  const [secondTier, setSecondTier] = useState(() => randomTier(DROP_MIN_TIER, dropMaxTier));

  const onStartRef = useRef(onStart);
  useEffect(() => {
    onStartRef.current = onStart;
  }, [onStart]);

  const onFinishRef = useRef(onFinish);
  useEffect(() => {
    onFinishRef.current = onFinish;
  }, [onFinish]);

  const statusRef = useRef<Status>("playing");
  const scoreRef = useRef(0);
  const nextTierRef = useRef(nextTier);
  const secondTierRef = useRef(secondTier);
  const dropXRef = useRef(CONTAINER_WIDTH / 2);
  const engineRef = useRef<Matter.Engine | null>(null);
  const bodiesRef = useRef(new Map<number, Matter.Body>());
  const nextPieceIdRef = useRef(0);
  const lastDropAtRef = useRef(-Infinity);
  const pendingMergesRef = useRef<PendingMerge[]>([]);
  const mergingBodyIdsRef = useRef(new Set<number>());
  const dangerSinceRef = useRef<number | null>(null);
  // Pas Date.now() directement en argument de useRef (impur pendant le
  // rendu, react-hooks/purity) : la vraie valeur est posée dans l'effet de
  // montage juste en dessous — resetGame() (rejouer après une partie) la
  // réécrit de toute façon à chaque nouvelle partie.
  const runStartedAtRef = useRef(0);
  useEffect(() => {
    runStartedAtRef.current = Date.now();
    onStartRef.current();
  }, []);
  const dropCountRef = useRef(0);
  const mergeCountRef = useRef(0);

  const spawnPiece = useCallback(
    (tier: number, x: number, y: number): Piece => {
      const id = nextPieceIdRef.current++;
      const radius = tierRadii[tier];
      const body = Matter.Bodies.circle(x, y, radius, {
        restitution: 0.2,
        friction: 0.3,
        frictionAir: 0.001,
      });
      const meta: BodyMeta = { pieceId: id, tier };
      body.plugin = meta;
      Matter.Composite.add(engineRef.current!.world, body);
      bodiesRef.current.set(id, body);
      return { id, tier };
    },
    [tierRadii],
  );

  // Positionne dropXRef (utilisé au lâcher) ainsi que la preview et le
  // trait de visée qui suivent le pointeur — rappelée après chaque
  // changement de palier (le rayon change, donc le décalage aussi) pour
  // resynchroniser sans jamais lire de ref pendant un rendu (interdit par
  // react-hooks/refs).
  const setDropX = useCallback(
    (x: number) => {
      const radius = tierRadii[nextTierRef.current];
      // Pas de WALL_THICKNESS ici : les murs physiques sont déjà à ras des
      // bords visuels du bac (leur face intérieure est exactement à x=0 et
      // x=CONTAINER_WIDTH), donc seul le rayon de la pièce limite jusqu'où
      // son centre peut aller. Ajouter WALL_THICKNESS en plus créait une
      // marge fixe de 20px des deux côtés — disproportionnée pour les
      // petits paliers, qui ne pouvaient plus s'approcher des bords.
      const clamped = Math.min(CONTAINER_WIDTH - radius, Math.max(radius, x));
      dropXRef.current = clamped;
      if (previewRef.current) {
        previewRef.current.style.transform = `translateX(${clamped - radius}px)`;
      }
      if (guideLineRef.current) {
        guideLineRef.current.style.transform = `translateX(${clamped}px)`;
      }
    },
    [tierRadii],
  );

  const resetGame = useCallback(() => {
    const engine = engineRef.current;
    if (engine) {
      for (const body of bodiesRef.current.values()) {
        Matter.Composite.remove(engine.world, body);
      }
    }
    bodiesRef.current.clear();
    nodeRefs.current.clear();
    pendingMergesRef.current = [];
    mergingBodyIdsRef.current.clear();
    dangerSinceRef.current = null;
    runStartedAtRef.current = Date.now();
    onStartRef.current();
    dropCountRef.current = 0;
    mergeCountRef.current = 0;
    lastDropAtRef.current = -Infinity;
    scoreRef.current = 0;
    statusRef.current = "playing";
    const firstNextTier = randomTier(DROP_MIN_TIER, dropMaxTier);
    const firstSecondTier = randomTier(DROP_MIN_TIER, dropMaxTier);
    nextTierRef.current = firstNextTier;
    secondTierRef.current = firstSecondTier;
    setNextTier(firstNextTier);
    setSecondTier(firstSecondTier);
    setPieces([]);
    setScore(0);
    setStatus("playing");
    setDropX(CONTAINER_WIDTH / 2);
  }, [dropMaxTier, setDropX]);

  const handleDrop = useCallback(() => {
    if (statusRef.current !== "playing") return;
    const now = performance.now();
    if (now - lastDropAtRef.current < DROP_COOLDOWN_MS) return;
    lastDropAtRef.current = now;

    const tier = nextTierRef.current;
    const radius = tierRadii[tier];
    // Même borne que setDropX (pas de WALL_THICKNESS en trop, voir
    // là-bas) — dropXRef est déjà clampé de la même façon, ce recalcul ne
    // fait que se protéger d'un dropXRef obtenu autrement (clavier, etc.).
    const minX = radius;
    const maxX = CONTAINER_WIDTH - radius;
    const x = Math.min(maxX, Math.max(minX, dropXRef.current));
    // spawnPiece a des effets de bord (crée un vrai corps matter-js,
    // incrémente le compteur d'id) : appelé une seule fois ici, jamais
    // depuis l'intérieur du updater de setPieces, qui doit rester pur.
    const dropped = spawnPiece(tier, x, radius + 6);
    dropCountRef.current += 1;
    setPieces((current) => [...current, dropped]);

    // La file glisse d'un cran : ce qui était annoncé en second devient la
    // pièce à viser, et un nouveau palier est tiré pour la deuxième place.
    const upcoming = secondTierRef.current;
    const newSecond = randomTier(DROP_MIN_TIER, dropMaxTier);
    nextTierRef.current = upcoming;
    secondTierRef.current = newSecond;
    setNextTier(upcoming);
    setSecondTier(newSecond);
    setDropX(dropXRef.current);
  }, [spawnPiece, setDropX, tierRadii, dropMaxTier]);

  const updateDropXFromEvent = useCallback(
    (event: ReactPointerEvent) => {
      const rect = containerRef.current?.getBoundingClientRect();
      if (!rect) return;
      setDropX(event.clientX - rect.left);
    },
    [setDropX],
  );

  // Sur un tap sans glissement (mobile notamment), pointermove ne se
  // déclenche pas forcément avant pointerup — sans repositionner ici aussi,
  // le lâcher se faisait à l'ancienne position (celle d'une interaction
  // précédente), pas là où le doigt vient de taper. onPointerDown en plus
  // pour que le trait de visée bouge dès le contact, pas seulement au
  // relâchement.
  const handlePointerUp = useCallback(
    (event: ReactPointerEvent) => {
      updateDropXFromEvent(event);
      handleDrop();
    },
    [updateDropXFromEvent, handleDrop],
  );

  // Clavier (ordinateur) : flèches pour viser, Espace/Entrée pour lâcher —
  // même logique que le support clavier ajouté au Vol d'Icare.
  useEffect(() => {
    function handleKeyDown(event: KeyboardEvent) {
      if (event.code === "ArrowLeft" || event.code === "ArrowRight") {
        event.preventDefault();
        const step = event.code === "ArrowLeft" ? -16 : 16;
        setDropX(dropXRef.current + step);
      } else if (event.code === "Space" || event.code === "Enter") {
        event.preventDefault();
        handleDrop();
      }
    }
    window.addEventListener("keydown", handleKeyDown);
    return () => window.removeEventListener("keydown", handleKeyDown);
  }, [handleDrop, setDropX]);

  useEffect(() => {
    const engine = Matter.Engine.create({ gravity: { x: 0, y: 1 } });
    engineRef.current = engine;

    const floor = Matter.Bodies.rectangle(
      CONTAINER_WIDTH / 2,
      CONTAINER_HEIGHT + WALL_THICKNESS / 2,
      CONTAINER_WIDTH,
      WALL_THICKNESS,
      { isStatic: true },
    );
    const leftWall = Matter.Bodies.rectangle(
      -WALL_THICKNESS / 2,
      CONTAINER_HEIGHT / 2,
      WALL_THICKNESS,
      CONTAINER_HEIGHT * 2,
      { isStatic: true },
    );
    const rightWall = Matter.Bodies.rectangle(
      CONTAINER_WIDTH + WALL_THICKNESS / 2,
      CONTAINER_HEIGHT / 2,
      WALL_THICKNESS,
      CONTAINER_HEIGHT * 2,
      { isStatic: true },
    );
    Matter.Composite.add(engine.world, [floor, leftWall, rightWall]);

    // Les fusions sont détectées ici mais traitées après Engine.update (pas
    // pendant), pour ne jamais modifier le monde matter-js au milieu d'un
    // pas de simulation.
    Matter.Events.on(engine, "collisionStart", (event) => {
      for (const pair of event.pairs) {
        const metaA = pair.bodyA.plugin as BodyMeta | undefined;
        const metaB = pair.bodyB.plugin as BodyMeta | undefined;
        if (!metaA || !metaB) continue;
        if (metaA.tier !== metaB.tier) continue;
        if (mergingBodyIdsRef.current.has(pair.bodyA.id) || mergingBodyIdsRef.current.has(pair.bodyB.id)) continue;

        mergingBodyIdsRef.current.add(pair.bodyA.id);
        mergingBodyIdsRef.current.add(pair.bodyB.id);
        pendingMergesRef.current.push({
          pieceIdA: metaA.pieceId,
          pieceIdB: metaB.pieceId,
          tier: metaA.tier,
          x: (pair.bodyA.position.x + pair.bodyB.position.x) / 2,
          y: (pair.bodyA.position.y + pair.bodyB.position.y) / 2,
        });
      }
    });

    let rafId: number;
    let lastFrame: number | null = null;
    let accumulator = 0;

    function processPendingMerges() {
      if (pendingMergesRef.current.length === 0) return;
      const merges = pendingMergesRef.current;
      pendingMergesRef.current = [];

      const removedIds: number[] = [];
      const spawned: Piece[] = [];

      for (const merge of merges) {
        const bodyA = bodiesRef.current.get(merge.pieceIdA);
        const bodyB = bodiesRef.current.get(merge.pieceIdB);
        if (!bodyA || !bodyB) continue;

        Matter.Composite.remove(engine.world, [bodyA, bodyB]);
        mergeCountRef.current += 1;
        bodiesRef.current.delete(merge.pieceIdA);
        bodiesRef.current.delete(merge.pieceIdB);
        nodeRefs.current.delete(merge.pieceIdA);
        nodeRefs.current.delete(merge.pieceIdB);
        removedIds.push(merge.pieceIdA, merge.pieceIdB);

        if (merge.tier < maxTier) {
          const newTier = merge.tier + 1;
          scoreRef.current += newTier;
          spawned.push(spawnPiece(newTier, merge.x, merge.y));
          playMergeSound(newTier, maxTier);
        } else {
          // Palier maximum déjà atteint : pas de pièce plus grande, juste
          // un bonus de score pour la fusion finale.
          scoreRef.current += maxTier * 2;
          playMergeSound(maxTier, maxTier);
        }
      }

      setScore(scoreRef.current);
      setPieces((current) => [...current.filter((p) => !removedIds.includes(p.id)), ...spawned]);
    }

    // Renvoie les corps qui dépassent la ligne de danger (au repos, pas un
    // simple rebond) — utilisé à la fois pour déclencher/prolonger le
    // délai de grâce et pour savoir quelles pièces doivent clignoter. Le
    // cercle doit avoir ENTIÈREMENT franchi la ligne (son bord bas,
    // position.y + radius) plutôt que simplement y toucher (son bord haut,
    // position.y - radius) : une pièce qui dépasse juste du sommet ne doit
    // pas encore compter.
    function overflowingBodyIds(): Set<number> {
      const ids = new Set<number>();
      for (const [id, body] of bodiesRef.current) {
        const radius = body.circleRadius ?? 0;
        if (body.position.y + radius < DANGER_LINE_Y && body.speed < 0.5) {
          ids.add(id);
        }
      }
      return ids;
    }

    function frame(now: number) {
      if (lastFrame === null) lastFrame = now;
      const frameDt = Math.min(now - lastFrame, MAX_FRAME_DT_MS);
      lastFrame = now;

      let overflowing = new Set<number>();

      if (statusRef.current === "playing") {
        accumulator += frameDt;
        while (accumulator >= FIXED_DT_MS) {
          Matter.Engine.update(engine, FIXED_DT_MS);
          processPendingMerges();
          accumulator -= FIXED_DT_MS;
        }

        overflowing = overflowingBodyIds();
        if (overflowing.size > 0) {
          if (dangerSinceRef.current === null) {
            dangerSinceRef.current = now;
          } else if (now - dangerSinceRef.current >= GAME_OVER_GRACE_MS) {
            statusRef.current = "dead";
            setStatus("dead");
            onFinishRef.current(scoreRef.current, {
              v: 1,
              duration_ms: Math.max(0, Date.now() - runStartedAtRef.current),
              actions: dropCountRef.current,
              merges: mergeCountRef.current,
            });
          }
        } else {
          dangerSinceRef.current = null;
        }
      }

      // translate d'abord (positionne le centre), rotate ensuite (tourne
      // autour de ce centre, transform-origin par défaut) — sans ça les
      // pièces glissaient sans jamais rouler, ce qui les faisait paraître
      // figées/anguleuses plutôt que rondes. Le clignotement (classList,
      // pas de state React) prévient que le délai de grâce avant game over
      // est en cours pour cette pièce précisément.
      for (const [id, body] of bodiesRef.current) {
        const node = nodeRefs.current.get(id);
        if (!node) continue;
        const radius = body.circleRadius ?? 0;
        node.style.transform = `translate(${body.position.x - radius}px, ${body.position.y - radius}px) rotate(${body.angle}rad)`;
        node.classList.toggle("animate-danger-blink", overflowing.has(id));
      }

      rafId = requestAnimationFrame(frame);
    }

    rafId = requestAnimationFrame(frame);

    return () => {
      cancelAnimationFrame(rafId);
      Matter.Events.off(engine, "collisionStart");
      Matter.Composite.clear(engine.world, false);
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps -- monté une seule fois ; spawnPiece/maxTier sont stables tant que citizens.length ne change pas
  }, []);

  const prefersReducedMotion =
    typeof window !== "undefined" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;

  return (
    <div className="flex flex-col items-center gap-3">
      <div className="flex items-center justify-between" style={{ width: CONTAINER_WIDTH }}>
        <p className="font-heading text-2xl text-gold-bright">{score}</p>

        {/* Deuxième pièce annoncée (après celle à viser) : juste
            informatif, taille d'affichage fixe quel que soit le vrai
            rayon du palier — pas de position à viser pour celle-ci. */}
        <div className="flex items-center gap-1.5 opacity-70">
          <span className="font-heading text-[0.6rem] tracking-wide text-marble/50 uppercase">Puis</span>
          <Avatar
            pseudo={citizens[secondTier]?.pseudo ?? "?"}
            avatarUrl={citizens[secondTier]?.avatar_url ?? null}
            size={32}
          />
        </div>
      </div>

      {/* La bordure vit sur ce conteneur externe (sans largeur/hauteur
          fixées : il s'ajuste automatiquement autour de l'enfant). La
          bordure participant au box-sizing, la mettre directement sur le
          conteneur aux coordonnées physiques (300×420) réduisait sa zone
          intérieure réelle à 292×416 sans que la physique le sache — les
          pièces pouvaient donc s'empiler jusqu'à x=300 alors que le cadre
          visible s'arrêtait à 292, d'où le débordement à droite/en bas. */}
      <div className="rounded-b-2xl border-x-4 border-b-4 border-gold/50">
        <div
          ref={containerRef}
          onPointerDown={updateDropXFromEvent}
          onPointerMove={updateDropXFromEvent}
          onPointerUp={handlePointerUp}
          className="relative touch-none overflow-visible rounded-b-2xl bg-ink-2/40 select-none"
          style={{ width: CONTAINER_WIDTH, height: CONTAINER_HEIGHT }}
        >
        <div
          className="pointer-events-none absolute inset-x-0 border-t border-dashed border-oxblood/50"
          style={{ top: DANGER_LINE_Y }}
        />

        {/* Trait de visée vertical, sous la preview, jusqu'au fond du bac —
            positionné via le même setDropX que la preview. */}
        <div
          ref={(node) => {
            guideLineRef.current = node;
            if (node) node.style.transform = `translateX(${dropXRef.current}px)`;
          }}
          className="pointer-events-none absolute top-0 bottom-0 w-px border-l border-dashed border-gold/40"
        />

        {pieces.map((piece) => {
          const citizen = citizens[piece.tier];
          const diameter = tierRadii[piece.tier] * 2;
          return (
            <div
              key={piece.id}
              ref={(node) => {
                if (node) nodeRefs.current.set(piece.id, node);
              }}
              className="pointer-events-none absolute top-0 left-0"
            >
              {/* La position/rotation (transform) de ce conteneur est
                  pilotée impérativement à chaque frame depuis la boucle
                  physique — l'animation d'apparition vit sur cet enfant,
                  jamais sur le conteneur, pour ne pas entrer en conflit
                  avec transform. Idem pour animate-danger-blink (ajoutée/
                  retirée sur ce même conteneur par la boucle physique quand
                  la pièce dépasse la ligne de danger). rounded-full ici :
                  sans ça le halo de l'animation dessinait un carré visible
                  autour du rond. animate-fade-in (pas animate-reveal) : ce
                  dernier passe par 82% puis 104% avant de se stabiliser à
                  100%, ce qui faisait paraître une pièce tout juste lâchée
                  d'une taille différente de celle annoncée dans la
                  preview. */}
              <div className="animate-fade-in rounded-full">
                <Avatar pseudo={citizen?.pseudo ?? "?"} avatarUrl={citizen?.avatar_url ?? null} size={diameter} />
              </div>
            </div>
          );
        })}

        <div
          ref={(node) => {
            previewRef.current = node;
            // Position initiale posée ici (callback de ref, exécuté au
            // montage du nœud) plutôt que dans un effet : un ref ne peut
            // pas être lu pendant le rendu, et setDropX (appelé depuis
            // resetGame/handleDrop) prend le relais pour toute mise à jour
            // ultérieure.
            if (node) {
              const radius = tierRadii[nextTierRef.current];
              node.style.transform = `translateX(${dropXRef.current - radius}px)`;
            }
          }}
          className="pointer-events-none absolute left-0"
          style={{ top: -(tierRadii[nextTier] * 2 + 12) }}
        >
          <Avatar
            pseudo={citizens[nextTier]?.pseudo ?? "?"}
            avatarUrl={citizens[nextTier]?.avatar_url ?? null}
            size={tierRadii[nextTier] * 2}
          />
        </div>

        {status === "dead" && !prefersReducedMotion && <Confetti />}

        {status === "dead" && (
          <div className="absolute inset-0 flex items-center justify-center rounded-b-2xl bg-ink/70">
            <div className="marble-surface animate-reveal flex flex-col items-center gap-2 rounded-xl border border-gold/40 px-6 py-4 text-center shadow-lg">
              <p className="text-xs tracking-wide text-text-mut uppercase">Score</p>
              <p className="font-heading text-3xl text-gold-bright">{score}</p>
              <button
                type="button"
                onPointerUp={(event) => event.stopPropagation()}
                onClick={resetGame}
                className="mt-1 rounded-md bg-ink-2 px-4 py-2 font-heading text-sm tracking-wide text-gold-bright uppercase transition hover:bg-ink"
              >
                Rejouer
              </button>
            </div>
          </div>
        )}
        </div>
      </div>

      <p className="text-center text-xs text-marble/50">
        Touche/clique pour viser et lâche — deux avatars de même taille fusionnent.
      </p>

      {/* Échelle de repère : ordre de fusion complet, du plus petit au plus
          grand — taille d'affichage proportionnelle au vrai rayon du
          palier (mais réduite et plafonnée) pour que la progression se
          voie d'un coup d'œil, sans dépendre du nombre de Citoyens.
          flex-wrap (pas de scroll horizontal comme avant) : tout le monde
          doit être visible sans interaction, l'intérêt d'une échelle de
          repère est justement de tout voir d'un coup plutôt que de devoir
          faire défiler. items-center (pas items-end) : aligner sur le bas
          avec des tailles très variables donnait un décalage qui
          grandissait avec la taille (la flèche, à décalage fixe, n'était
          plus au bon endroit pour les gros ronds) — centrer chaque rond
          sur son propre milieu reste symétrique quelle que soit sa
          taille. Largeur plafonnée à max-w-sm (pas CONTAINER_WIDTH comme
          avant) : la page (corne-view.tsx) est bien plus large que le bac
          de jeu, la contrainte à 300px n'avait pas lieu d'être et forçait
          le scroll pour rien. */}
      <div className="marble-surface w-full max-w-sm rounded-lg border border-gold/25 p-3">
        <p className="mb-2 text-center font-heading text-[0.65rem] tracking-[0.15em] text-text-mut uppercase">
          Ordre de fusion
        </p>
        <div className="flex flex-wrap items-center justify-center gap-x-1.5 gap-y-3">
          {citizens.map((citizen, tier) => (
            <div key={citizen.id} className="flex items-center gap-1.5">
              <Avatar
                pseudo={citizen.pseudo}
                avatarUrl={citizen.avatar_url}
                size={Math.round(18 + (tierRadii[tier] / tierRadii[maxTier]) * 26)}
              />
              {tier < citizens.length - 1 && <span className="text-xs text-gold/50">→</span>}
            </div>
          ))}
        </div>
      </div>
    </div>
  );
}
