"use client";

/**
 * Force-directed canvas for the corpus graph (Obsidian-graph-view-like).
 *
 * Uses react-force-graph-2d (HTML5 canvas + d3-force) — the live physics give
 * the "alive" Obsidian feel, and `nodeCanvasObject` gives full control over
 * node radius (size = citation count) and Hebrew/RTL label rendering. Loaded
 * via next/dynamic with ssr:false because the canvas needs the DOM (Next 16).
 */

import dynamic from "next/dynamic";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";

import type { CorpusGraph, GraphNode } from "@/lib/api/graph";

// react-force-graph-2d's default export is a forwardRef component; it touches
// `window` at module load, so it must be client-only. Cast to a loose type:
// next/dynamic's wrapper doesn't surface the lib's ref/prop types cleanly, and
// the node/link callbacks below are independently typed (FGNode/FGLink).
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const ForceGraph2D: any = dynamic(() => import("react-force-graph-2d"), {
  ssr: false,
  loading: () => (
    <div className="grid h-full w-full place-items-center text-sm text-ink-muted">
      טוען גרף…
    </div>
  ),
});

// Internal shapes the canvas works with (force-graph mutates these).
type FGNode = GraphNode & { x?: number; y?: number };
type FGLink = {
  source: string;
  target: string;
  type: string;
  treatment: string | null;
};

export type ColorBy =
  | "type"
  | "practice_area"
  | "community"
  | "precedent_level"
  | "recency";
export type SizeBy = "indegree" | "pagerank" | "betweenness";

const NODE_COLORS: Record<string, string> = {
  precedent: "#1e3a5f", // navy
  halacha: "#b45309", // amber
  topic: "#a97d3a", // gold — hubs stand out
  practice_area: "#475569", // slate
  gap: "#94a3b8", // faint slate — research gap (not in corpus)
};

const TREATMENT_COLORS: Record<string, string> = {
  overrule: "#b91c1c",
  overruled: "#b91c1c",
  distinguish: "#d97706",
  distinguished: "#d97706",
};

// Distinct, cyclic palette for community (cluster) colouring.
export const COMMUNITY_PALETTE = [
  "#1e3a5f", "#a97d3a", "#3b7a57", "#8c3b4a", "#5b4b8a", "#b06a2c",
  "#2f6f7a", "#9a4f6a", "#46688a", "#7d6b3a", "#6b7280", "#4d7c5a",
];

// Authority hierarchy: עליון darkest → ועדת ערר lightest.
export const LEVEL_COLORS: Record<string, string> = {
  עליון: "#13294b",
  מנהלי: "#3b6ea5",
  ועדת_ערר_מחוזית: "#8fb0cf",
};

export const PA_COLORS: Record<string, string> = {
  rishuy_uvniya: "#1e3a5f",
  betterment_levy: "#a97d3a",
  compensation_197: "#3b7a57",
};

const FALLBACK_COLOR = "#94a3b8";

/** Old (slate) → recent (gold) gradient over 1994–2026. */
export function recencyColor(dateStr: string | null): string {
  if (!dateStr) return FALLBACK_COLOR;
  const y = Number(dateStr.slice(0, 4));
  if (!y) return FALLBACK_COLOR;
  const t = Math.max(0, Math.min(1, (y - 1994) / (2026 - 1994)));
  const oldC = [100, 116, 139];
  const newC = [169, 125, 58];
  const c = oldC.map((o, i) => Math.round(o + (newC[i] - o) * t));
  return `rgb(${c[0]},${c[1]},${c[2]})`;
}

export function colorForNode(n: GraphNode, colorBy: ColorBy): string {
  // Hubs always keep their type colour — only precedents recolour.
  if (n.type !== "precedent") return NODE_COLORS[n.type] ?? FALLBACK_COLOR;
  switch (colorBy) {
    case "practice_area":
      return PA_COLORS[n.practice_area ?? ""] ?? FALLBACK_COLOR;
    case "community":
      return n.community != null
        ? COMMUNITY_PALETTE[n.community % COMMUNITY_PALETTE.length]
        : FALLBACK_COLOR;
    case "precedent_level":
      return LEVEL_COLORS[n.precedent_level ?? ""] ?? FALLBACK_COLOR;
    case "recency":
      return recencyColor(n.date);
    default:
      return NODE_COLORS.precedent;
  }
}

export function radiusForNode(n: GraphNode, sizeBy: SizeBy): number {
  if (n.type === "topic" || n.type === "practice_area") return 5;
  if (sizeBy === "pagerank" && n.pagerank != null) {
    return 3 + Math.sqrt(n.pagerank) * 18;
  }
  if (sizeBy === "betweenness" && n.betweenness != null) {
    return 3 + Math.sqrt(n.betweenness) * 18;
  }
  return Math.min(22, 3 + Math.sqrt(Math.max(0, n.size)) * 1.7);
}

function useElementSize<T extends HTMLElement>() {
  const ref = useRef<T>(null);
  const [size, setSize] = useState({ width: 0, height: 0 });
  useEffect(() => {
    const el = ref.current;
    if (!el) return;
    const ro = new ResizeObserver((entries) => {
      const r = entries[0]?.contentRect;
      if (r) setSize({ width: Math.floor(r.width), height: Math.floor(r.height) });
    });
    ro.observe(el);
    return () => ro.disconnect();
  }, []);
  return { ref, size };
}

export function GraphCanvas({
  data,
  selectedId,
  onNodeClick,
  colorBy = "type",
  sizeBy = "indegree",
}: {
  data: CorpusGraph | undefined;
  selectedId: string | null;
  onNodeClick: (node: GraphNode) => void;
  colorBy?: ColorBy;
  sizeBy?: SizeBy;
}) {
  const { ref, size } = useElementSize<HTMLDivElement>();
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  const fgRef = useRef<any>(null);
  const [hoverId, setHoverId] = useState<string | null>(null);

  // Fresh objects each time `data` changes so force-graph can attach x/y
  // without mutating the TanStack Query cache.
  const graphData = useMemo(() => {
    if (!data) return { nodes: [] as FGNode[], links: [] as FGLink[] };
    return {
      nodes: data.nodes.map((n) => ({ ...n })) as FGNode[],
      links: data.edges.map((e) => ({
        source: e.source,
        target: e.target,
        type: e.type,
        treatment: e.treatment,
      })) as FGLink[],
    };
  }, [data]);

  // Adjacency for hover/selection highlighting (computed once per data change).
  const adjacency = useMemo(() => {
    const map = new Map<string, Set<string>>();
    for (const e of graphData.links) {
      if (!map.has(e.source)) map.set(e.source, new Set());
      if (!map.has(e.target)) map.set(e.target, new Set());
      map.get(e.source)!.add(e.target);
      map.get(e.target)!.add(e.source);
    }
    return map;
  }, [graphData]);

  const activeId = hoverId ?? selectedId;
  const activeNeighbors = activeId ? adjacency.get(activeId) : undefined;

  const isDimmed = useCallback(
    (id: string) => {
      if (!activeId) return false;
      if (id === activeId) return false;
      return !(activeNeighbors && activeNeighbors.has(id));
    },
    [activeId, activeNeighbors],
  );

  // Zoom-to-fit once physics settle.
  const handleEngineStop = useCallback(() => {
    fgRef.current?.zoomToFit?.(400, 60);
  }, []);

  // Spread nodes out so labels have room — stronger repulsion + longer links
  // than the d3-force defaults (charge -30, link 30). Re-applied whenever the
  // graph data changes (full ↔ neighborhood).
  useEffect(() => {
    const fg = fgRef.current;
    if (!fg?.d3Force) return;
    fg.d3Force("charge")?.strength?.(-220);
    fg.d3Force("link")?.distance?.(60);
    fg.d3ReheatSimulation?.();
  }, [graphData]);

  const drawNode = useCallback(
    (node: FGNode, ctx: CanvasRenderingContext2D, globalScale: number) => {
      const r = radiusForNode(node, sizeBy);
      const dimmed = isDimmed(node.id);
      const isGap = node.type === "gap";
      const color = colorForNode(node, colorBy);
      ctx.globalAlpha = dimmed ? 0.18 : isGap ? 0.55 : 1;

      ctx.beginPath();
      ctx.arc(node.x ?? 0, node.y ?? 0, r, 0, 2 * Math.PI);
      if (isGap) {
        // Hollow dashed circle — a ruling cited but absent from the corpus.
        ctx.setLineDash([3 / globalScale, 2 / globalScale]);
        ctx.lineWidth = 1.3 / globalScale;
        ctx.strokeStyle = NODE_COLORS.gap;
        ctx.stroke();
        ctx.setLineDash([]);
      } else {
        ctx.fillStyle = color;
        ctx.fill();
      }
      if (node.id === activeId) {
        ctx.lineWidth = 2 / globalScale;
        ctx.strokeStyle = "#a97d3a";
        ctx.stroke();
      }

      // Labels — gated by zoom so they don't pile up. At overview zoom only a
      // few show (active node, the 3 practice-area hubs, the most-cited
      // precedents); zooming in reveals the rest, by which point there is pixel
      // room between nodes. Font is kept at a ~constant SCREEN size (px /
      // globalScale) instead of growing as you zoom out — that growth was what
      // made labels collide.
      const isTopicHub = node.type === "topic";
      const isAreaHub = node.type === "practice_area";
      const showLabel =
        !dimmed &&
        (node.id === activeId ||
          isAreaHub ||
          globalScale >= 1.5 ||
          (isTopicHub && globalScale >= 1.05) ||
          (!isTopicHub && node.size >= 4 && globalScale >= 0.9));
      if (showLabel && node.label) {
        const fontPx = isTopicHub || isAreaHub ? 12 : 11;
        const fontSize = fontPx / globalScale; // ~constant on screen
        ctx.font = `${fontSize}px Heebo, sans-serif`;
        ctx.textAlign = "center";
        ctx.textBaseline = "top";
        ctx.direction = "rtl";
        const label =
          node.label.length > 22 ? `${node.label.slice(0, 21)}…` : node.label;
        const lx = node.x ?? 0;
        const ly = (node.y ?? 0) + r + 2 / globalScale;
        // White halo so the text stays readable over edges and nearby nodes.
        ctx.lineWidth = 3 / globalScale;
        ctx.strokeStyle = "rgba(255,255,255,0.92)";
        ctx.lineJoin = "round";
        ctx.strokeText(label, lx, ly);
        ctx.fillStyle = isTopicHub || isAreaHub ? "#7a5a26" : "#1a1a2e";
        ctx.fillText(label, lx, ly);
      }
      ctx.globalAlpha = 1;
    },
    [activeId, isDimmed, colorBy, sizeBy],
  );

  const drawPointerArea = useCallback(
    (node: FGNode, color: string, ctx: CanvasRenderingContext2D) => {
      ctx.fillStyle = color;
      ctx.beginPath();
      ctx.arc(node.x ?? 0, node.y ?? 0, radiusForNode(node, sizeBy) + 2, 0, 2 * Math.PI);
      ctx.fill();
    },
    [sizeBy],
  );

  const linkColor = useCallback(
    (link: FGLink) => {
      const s = typeof link.source === "object" ? (link.source as FGNode).id : link.source;
      const t = typeof link.target === "object" ? (link.target as FGNode).id : link.target;
      const active = activeId && (s === activeId || t === activeId);
      if (active) return "rgba(169,125,58,0.85)";
      if (activeId) return "rgba(120,130,150,0.06)";
      if (link.treatment && TREATMENT_COLORS[link.treatment]) {
        return TREATMENT_COLORS[link.treatment];
      }
      if (link.type === "tagged" || link.type === "in_area") {
        return "rgba(169,125,58,0.16)";
      }
      return "rgba(80,90,110,0.22)";
    },
    [activeId],
  );

  if (!size.width && !data) {
    return <div ref={ref} className="h-full w-full" />;
  }

  return (
    <div ref={ref} className="h-full w-full">
      {size.width > 0 && (
        <ForceGraph2D
          ref={fgRef}
          width={size.width}
          height={size.height}
          graphData={graphData}
          nodeId="id"
          nodeLabel={(n: FGNode) => n.label}
          nodeCanvasObject={drawNode}
          nodePointerAreaPaint={drawPointerArea}
          linkColor={linkColor}
          linkWidth={(l: FGLink) => {
            const s = typeof l.source === "object" ? (l.source as FGNode).id : l.source;
            const t = typeof l.target === "object" ? (l.target as FGNode).id : l.target;
            return activeId && (s === activeId || t === activeId) ? 1.6 : 0.6;
          }}
          linkDirectionalArrowLength={(l: FGLink) => (l.type === "cites" ? 2.4 : 0)}
          linkDirectionalArrowRelPos={1}
          onNodeClick={(n: FGNode) => onNodeClick(n)}
          onNodeHover={(n: FGNode | null) => setHoverId(n?.id ?? null)}
          onEngineStop={handleEngineStop}
          cooldownTicks={120}
          warmupTicks={20}
        />
      )}
    </div>
  );
}
