import { useEffect, useRef, useState } from 'react';
import type { Point, Viewport } from '@/lib/map';
import { fitViewport, zoomToPoint } from '@/lib/map';
import { cn } from '@/lib/cn';

/** Render-only drawing shape (both MapDrawing and PlayerDrawing satisfy this). */
export interface CanvasDrawing {
  id: string;
  kind: 'freehand' | 'line' | 'rect' | 'circle' | 'arrow' | 'text';
  points: Point[];
  color: string;
  width: number;
  text?: string | undefined;
}

/** Minimal token shape both the editor (MapToken) and player view (PlayerToken) satisfy. */
export interface CanvasToken {
  id: string;
  label: string;
  color: string;
  col: number;
  row: number;
  size: number;
  kind: 'pc' | 'npc' | 'monster' | 'object';
  /** resolved icon data URL (token image or linked character portrait) */
  image?: string | undefined;
  hp?: { current: number; max: number; temp: number } | undefined;
  conditions: { name: string; value?: number | undefined }[];
}

export interface CanvasView {
  image?: string | undefined;
  gridSize: number;
  showGrid: boolean;
  fogEnabled: boolean;
  revealed: string[];
  tokens: CanvasToken[];
  drawings: CanvasDrawing[];
  /** sight-blocking walls, drawn faintly for the GM (not sent to players) */
  walls?: { points: Point[] }[] | undefined;
}

export interface Overlay {
  cells?: string[];
  line?: { a: Point; b: Point } | undefined;
  label?: { at: Point; text: string } | undefined;
  draft?: CanvasDrawing | undefined;
  /** in-progress polygon: committed vertices + a rubber-band point at the cursor */
  poly?: { points: Point[]; cursor?: Point | undefined } | undefined;
  /** brush footprint at the cursor (radius in cells) */
  brush?: { col: number; row: number; size: number } | undefined;
}

type Phase = 'down' | 'move' | 'up' | 'hover';

interface Props {
  view: CanvasView;
  /** CSS height of the clip viewport (e.g. 'calc(100vh - 14rem)') */
  viewportHeight?: string;
  readOnly?: boolean;
  /** dim unrevealed cells fully (player view) vs. translucent (GM editor) */
  playerFog?: boolean;
  overlay?: Overlay;
  onPointer?: (phase: Phase, p: { point: Point; col: number; row: number }) => void;
  onLeave?: () => void;
  onTokenMove?: (id: string, col: number, row: number) => void;
  onTokenClick?: (id: string) => void;
  tokensDraggable?: boolean;
  onReady?: (info: { cols: number; rows: number }) => void;
}

const MAX_DPR = 2.5;

/**
 * Shared map renderer. A single canvas draws the image + grid + fog + drawings +
 * overlay with a camera transform (translate+scale) at the device's pixel
 * resolution, so everything stays crisp at any zoom (unlike CSS-scaling a
 * natural-resolution bitmap). Tokens are DOM, in a matching transformed layer.
 */
export function MapCanvas({ view, viewportHeight = '70vh', readOnly, playerFog, overlay, onPointer, onLeave, onTokenMove, onTokenClick, tokensDraggable, onReady }: Props) {
  const [natural, setNatural] = useState<{ w: number; h: number } | null>(null);
  const [vp, setVp] = useState<Viewport>({ zoom: 1, panX: 0, panY: 0 });
  const [size, setSize] = useState({ w: 0, h: 0 });
  const outerRef = useRef<HTMLDivElement>(null);
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const imgRef = useRef<HTMLImageElement | null>(null);
  const fittedRef = useRef<string | undefined>(undefined);

  const gridSize = view.gridSize;
  const W = natural?.w ?? 0;
  const H = natural?.h ?? 0;
  const cols = natural ? Math.ceil(W / gridSize) : 0;
  const rows = natural ? Math.ceil(H / gridSize) : 0;

  // Load the image bitmap (used both for sizing and for canvas drawImage).
  useEffect(() => {
    if (!view.image) { setNatural(null); imgRef.current = null; return; }
    const img = new Image();
    img.onload = () => { imgRef.current = img; setNatural({ w: img.naturalWidth, h: img.naturalHeight }); };
    img.src = view.image;
  }, [view.image]);

  useEffect(() => { if (natural) onReady?.({ cols, rows }); }, [cols, rows, natural, onReady]);

  // Track the viewport's CSS size.
  useEffect(() => {
    const el = outerRef.current;
    if (!el) return;
    const update = () => setSize({ w: el.clientWidth, h: el.clientHeight });
    update();
    const ro = new ResizeObserver(update);
    ro.observe(el);
    return () => ro.disconnect();
  }, []);

  // Fit once per image, as soon as both the image and viewport size are known.
  useEffect(() => {
    if (!natural || size.w === 0 || fittedRef.current === view.image) return;
    fittedRef.current = view.image;
    setVp(fitViewport(size.w, size.h, natural.w, natural.h));
  }, [natural, size, view.image]);

  // Wheel zoom (native + non-passive so the page doesn't scroll).
  useEffect(() => {
    const el = outerRef.current;
    if (!el) return;
    const onWheel = (e: WheelEvent) => {
      e.preventDefault();
      const r = el.getBoundingClientRect();
      setVp((v) => zoomToPoint(v, Math.exp(-e.deltaY * 0.0015), e.clientX - r.left, e.clientY - r.top));
    };
    el.addEventListener('wheel', onWheel, { passive: false });
    return () => el.removeEventListener('wheel', onWheel);
  }, []);

  // Redraw whenever camera/content/size changes (rAF-coalesced).
  useEffect(() => {
    const raf = requestAnimationFrame(() => {
      drawScene(canvasRef.current, imgRef.current, size.w, size.h, {
        vp, W, H, gridSize, cols, rows,
        showGrid: view.showGrid, fogEnabled: view.fogEnabled, revealed: view.revealed,
        playerFog: !!playerFog, drawings: view.drawings, overlay, walls: view.walls,
      });
    });
    return () => cancelAnimationFrame(raf);
  });

  const toPoint = (e: React.PointerEvent): { point: Point; col: number; row: number } => {
    const r = outerRef.current!.getBoundingClientRect();
    const x = (e.clientX - r.left - vp.panX) / vp.zoom;
    const y = (e.clientY - r.top - vp.panY) / vp.zoom;
    return { point: { x, y }, col: Math.floor(x / gridSize), row: Math.floor(y / gridSize) };
  };

  const pan = useRef<{ x: number; y: number; px: number; py: number } | null>(null);
  const interactive = !readOnly && !!onPointer;

  const ixDown = (e: React.PointerEvent) => {
    const wantPan = e.button === 1 || e.button === 2 || !interactive;
    (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
    if (wantPan) { pan.current = { x: e.clientX, y: e.clientY, px: vp.panX, py: vp.panY }; e.preventDefault(); return; }
    onPointer!('down', toPoint(e));
  };
  const ixMove = (e: React.PointerEvent) => {
    if (pan.current) {
      const { x, y, px, py } = pan.current;
      setVp((v) => ({ ...v, panX: px + (e.clientX - x), panY: py + (e.clientY - y) }));
      return;
    }
    if (interactive) onPointer!(e.buttons ? 'move' : 'hover', toPoint(e));
  };
  const ixUp = (e: React.PointerEvent) => {
    if (pan.current) { pan.current = null; return; }
    if (interactive) onPointer!('up', toPoint(e));
  };

  const zoomCentered = (factor: number) => setVp((v) => zoomToPoint(v, factor, size.w / 2, size.h / 2));
  const fit = () => { if (natural) setVp(fitViewport(size.w, size.h, natural.w, natural.h)); };

  // NOTE: always render the outer container (even with no image yet) so the
  // ResizeObserver attaches. In the networked player view the map image arrives
  // late over WebSocket; if we early-returned, the observer never bound, `size`
  // stayed 0, fit() never ran, and the map showed at 100% instead of fit.
  return (
    <div ref={outerRef} data-testid="map-viewport" data-zoom={vp.zoom} className="relative overflow-hidden rounded-lg border border-line bg-surface" style={{ height: viewportHeight, touchAction: 'none' }}>
      {!view.image
        ? <p className="p-3 text-sm text-muted">No image.</p>
        : !natural
          ? <p className="p-3 text-sm text-muted">Loading map…</p>
          : null}

      <canvas ref={canvasRef} className="pointer-events-none absolute inset-0 h-full w-full" />

      {/* pointer surface (below tokens so token drags win; tokens are small) */}
      {view.image && (
        <div
          className="absolute inset-0"
          style={{ cursor: interactive ? 'crosshair' : 'grab', touchAction: 'none' }}
          onPointerDown={ixDown}
          onPointerMove={ixMove}
          onPointerUp={ixUp}
          onPointerLeave={() => { if (interactive) onLeave?.(); }}
          onContextMenu={(e) => e.preventDefault()}
        />
      )}

      {/* tokens: DOM positioned in screen space (no CSS scale → labels stay crisp) */}
      {natural && (
        <div className="pointer-events-none absolute inset-0">
          {view.tokens.map((t) => (
            <TokenChip key={t.id} token={t} gridSize={gridSize} vp={vp} cols={cols} rows={rows}
              draggable={!!tokensDraggable} onMove={onTokenMove} onClick={onTokenClick} />
          ))}
        </div>
      )}

      {natural && (
        <div className="absolute bottom-2 right-2 flex items-center gap-1 rounded-md border border-line bg-panel/90 px-1 py-0.5 text-xs shadow backdrop-blur">
          <button className="grid h-6 w-6 place-items-center rounded hover:bg-elevated" onClick={() => zoomCentered(1 / 1.25)} aria-label="Zoom out">−</button>
          <span className="w-10 text-center tabular-nums text-muted">{Math.round(vp.zoom * 100)}%</span>
          <button className="grid h-6 w-6 place-items-center rounded hover:bg-elevated" onClick={() => zoomCentered(1.25)} aria-label="Zoom in">+</button>
          <button className="grid h-6 place-items-center rounded px-1.5 hover:bg-elevated" onClick={fit} aria-label="Fit map to view" title="Fit">⊡</button>
        </div>
      )}
    </div>
  );
}

interface SceneParams {
  vp: Viewport;
  W: number; H: number; gridSize: number; cols: number; rows: number;
  showGrid: boolean; fogEnabled: boolean; revealed: string[]; playerFog: boolean;
  drawings: CanvasDrawing[]; overlay?: Overlay | undefined; walls?: { points: Point[] }[] | undefined;
}

/** Draw the whole scene at device resolution with a camera transform. */
function drawScene(canvas: HTMLCanvasElement | null, img: HTMLImageElement | null, vw: number, vh: number, p: SceneParams): void {
  if (!canvas || vw === 0 || vh === 0) return;
  const dpr = Math.min(window.devicePixelRatio || 1, MAX_DPR);
  const bw = Math.round(vw * dpr), bh = Math.round(vh * dpr);
  if (canvas.width !== bw || canvas.height !== bh) { canvas.width = bw; canvas.height = bh; }
  const ctx = canvas.getContext('2d');
  if (!ctx) return;
  const { zoom, panX, panY } = p.vp;
  // Camera maps world px → CSS px → device px. Device transform = dpr only.
  const cam = () => ctx.setTransform(zoom * dpr, 0, 0, zoom * dpr, panX * dpr, panY * dpr);
  const dev = () => ctx.setTransform(dpr, 0, 0, dpr, 0, 0);

  dev();
  ctx.clearRect(0, 0, vw, vh);
  if (!img) return;

  // Map image
  cam();
  ctx.imageSmoothingEnabled = true;
  ctx.imageSmoothingQuality = 'high';
  ctx.drawImage(img, 0, 0, p.W, p.H);

  // Fog (world space)
  if (p.fogEnabled) {
    const revealed = new Set(p.revealed);
    ctx.fillStyle = p.playerFog ? 'rgba(8,8,12,1)' : 'rgba(0,0,0,0.55)';
    for (let c = 0; c < p.cols; c++) for (let r = 0; r < p.rows; r++) {
      if (!revealed.has(`${c},${r}`)) ctx.fillRect(c * p.gridSize, r * p.gridSize, p.gridSize + 0.5, p.gridSize + 0.5);
    }
  }

  // Grid (device space → crisp 1px lines)
  if (p.showGrid) {
    const step = p.gridSize * zoom;
    if (step >= 4) {
      dev();
      ctx.strokeStyle = 'rgba(255,255,255,0.28)';
      ctx.lineWidth = 1;
      ctx.beginPath();
      const x0 = Math.max(0, panX), x1 = Math.min(vw, panX + p.W * zoom);
      const y0 = Math.max(0, panY), y1 = Math.min(vh, panY + p.H * zoom);
      for (let c = Math.max(0, Math.floor((-panX) / step)); c <= p.cols; c++) {
        const x = Math.round(panX + c * step) + 0.5;
        if (x < x0 - 1 || x > x1 + 1) continue;
        ctx.moveTo(x, y0); ctx.lineTo(x, y1);
      }
      for (let r = Math.max(0, Math.floor((-panY) / step)); r <= p.rows; r++) {
        const y = Math.round(panY + r * step) + 0.5;
        if (y < y0 - 1 || y > y1 + 1) continue;
        ctx.moveTo(x0, y); ctx.lineTo(x1, y);
      }
      ctx.stroke();
    }
  }

  // Drawings (world space; crisp under the camera transform)
  cam();
  for (const d of p.drawings) drawShape(ctx, d);

  // Walls (GM only — faint, so imported line-of-sight is visible while editing)
  if (p.walls?.length) {
    cam();
    ctx.strokeStyle = 'rgba(80,200,255,0.6)';
    ctx.lineWidth = 2 / zoom;
    ctx.lineJoin = 'round'; ctx.lineCap = 'round';
    for (const w of p.walls) {
      if (w.points.length < 2) continue;
      ctx.beginPath();
      ctx.moveTo(w.points[0]!.x, w.points[0]!.y);
      for (const pt of w.points.slice(1)) ctx.lineTo(pt.x, pt.y);
      ctx.stroke();
    }
  }

  // Overlay (AoE/measure/draft/poly/brush)
  const o = p.overlay;
  if (o) {
    cam();
    if (o.cells?.length) {
      ctx.fillStyle = 'rgba(212,175,55,0.35)';
      for (const k of o.cells) { const [c, r] = k.split(',').map(Number); ctx.fillRect(c! * p.gridSize, r! * p.gridSize, p.gridSize, p.gridSize); }
    }
    if (o.draft) drawShape(ctx, o.draft);
    if (o.brush) {
      ctx.strokeStyle = 'rgba(212,175,55,0.95)';
      ctx.lineWidth = 2 / zoom;
      const s = o.brush.size;
      ctx.strokeRect((o.brush.col - s) * p.gridSize, (o.brush.row - s) * p.gridSize, (s * 2 + 1) * p.gridSize, (s * 2 + 1) * p.gridSize);
    }
    if (o.poly && o.poly.points.length) {
      const pts = o.poly.points;
      ctx.strokeStyle = '#d4af37';
      ctx.lineWidth = 2 / zoom;
      ctx.beginPath();
      ctx.moveTo(pts[0]!.x, pts[0]!.y);
      for (const pt of pts.slice(1)) ctx.lineTo(pt.x, pt.y);
      if (o.poly.cursor) ctx.lineTo(o.poly.cursor.x, o.poly.cursor.y);
      if (pts.length >= 2) ctx.closePath();
      ctx.stroke();
      ctx.fillStyle = '#fff';
      for (const pt of pts) { ctx.beginPath(); ctx.arc(pt.x, pt.y, 4 / zoom, 0, Math.PI * 2); ctx.fill(); }
    }
    if (o.line) {
      ctx.strokeStyle = '#d4af37'; ctx.lineWidth = 2 / zoom;
      ctx.beginPath(); ctx.moveTo(o.line.a.x, o.line.a.y); ctx.lineTo(o.line.b.x, o.line.b.y); ctx.stroke();
    }
    if (o.label) {
      // label in screen space → fixed, readable size at any zoom
      dev();
      ctx.font = '600 13px sans-serif';
      ctx.fillStyle = '#fff'; ctx.strokeStyle = 'rgba(0,0,0,0.75)'; ctx.lineWidth = 3;
      const x = panX + o.label.at.x * zoom + 8, y = panY + o.label.at.y * zoom - 8;
      ctx.strokeText(o.label.text, x, y); ctx.fillText(o.label.text, x, y);
    }
  }
  dev();
}

/** Draw a vector annotation in world coordinates (crisp under the camera). */
function drawShape(ctx: CanvasRenderingContext2D, d: CanvasDrawing): void {
  ctx.strokeStyle = d.color; ctx.fillStyle = d.color; ctx.lineWidth = d.width; ctx.lineJoin = 'round'; ctx.lineCap = 'round';
  const p = d.points;
  if (d.kind === 'text' && d.text && p[0]) {
    ctx.font = `${Math.max(12, d.width * 5)}px sans-serif`;
    ctx.fillText(d.text, p[0].x, p[0].y);
    return;
  }
  if (p.length < 2) return;
  const a = p[0]!, b = p[p.length - 1]!;
  ctx.beginPath();
  if (d.kind === 'rect') {
    ctx.rect(Math.min(a.x, b.x), Math.min(a.y, b.y), Math.abs(b.x - a.x), Math.abs(b.y - a.y));
  } else if (d.kind === 'circle') {
    ctx.arc(a.x, a.y, Math.hypot(b.x - a.x, b.y - a.y), 0, Math.PI * 2);
  } else {
    ctx.moveTo(p[0]!.x, p[0]!.y);
    for (const pt of p.slice(1)) ctx.lineTo(pt.x, pt.y);
    if (d.kind === 'arrow') {
      const ang = Math.atan2(b.y - p[p.length - 2]!.y, b.x - p[p.length - 2]!.x), h = 10 + d.width;
      ctx.moveTo(b.x, b.y); ctx.lineTo(b.x - h * Math.cos(ang - 0.4), b.y - h * Math.sin(ang - 0.4));
      ctx.moveTo(b.x, b.y); ctx.lineTo(b.x - h * Math.cos(ang + 0.4), b.y - h * Math.sin(ang + 0.4));
    }
  }
  ctx.stroke();
}

function TokenChip({ token, gridSize, vp, cols, rows, draggable, onMove, onClick }: {
  token: CanvasToken; gridSize: number; vp: Viewport; cols: number; rows: number;
  draggable: boolean; onMove?: ((id: string, col: number, row: number) => void) | undefined; onClick?: ((id: string) => void) | undefined;
}) {
  const ref = useRef<HTMLDivElement>(null);
  const drag = useRef<{ x: number; y: number; col: number; row: number; moved: boolean } | null>(null);
  const [drift, setDrift] = useState({ dx: 0, dy: 0 });
  const { zoom, panX, panY } = vp;
  const span = gridSize * token.size * zoom; // on-screen px
  const box = span * 0.92;
  const frac = token.hp && token.hp.max > 0 ? Math.max(0, Math.min(1, token.hp.current / token.hp.max)) : null;

  const down = (e: React.PointerEvent) => {
    if (draggable) { e.stopPropagation(); ref.current?.setPointerCapture(e.pointerId); drag.current = { x: e.clientX, y: e.clientY, col: token.col, row: token.row, moved: false }; }
  };
  const move = (e: React.PointerEvent) => {
    if (!drag.current) return;
    const dx = e.clientX - drag.current.x, dy = e.clientY - drag.current.y;
    if (Math.abs(dx) > 3 || Math.abs(dy) > 3) drag.current.moved = true;
    setDrift({ dx, dy });
  };
  const up = (e: React.PointerEvent) => {
    if (!drag.current) return;
    e.stopPropagation();
    ref.current?.releasePointerCapture(e.pointerId);
    const moved = drag.current.moved;
    if (moved && onMove) {
      const col = Math.max(0, Math.min(cols - 1, drag.current.col + Math.round(drift.dx / (gridSize * zoom))));
      const row = Math.max(0, Math.min(rows - 1, drag.current.row + Math.round(drift.dy / (gridSize * zoom))));
      onMove(token.id, col, row);
    } else if (!moved && onClick) onClick(token.id);
    drag.current = null; setDrift({ dx: 0, dy: 0 });
  };

  return (
    <div
      ref={ref}
      onPointerDown={down} onPointerMove={move} onPointerUp={up}
      title={token.label}
      data-testid="map-token"
      className={cn('pointer-events-auto absolute grid place-items-center rounded-full border-2 border-black/50 font-bold leading-none text-black shadow', frac !== null && frac <= 0 && 'opacity-50')}
      style={{
        width: box, height: box,
        left: panX + token.col * gridSize * zoom + span * 0.04 + drift.dx,
        top: panY + token.row * gridSize * zoom + span * 0.04 + drift.dy,
        fontSize: Math.max(8, Math.min(22, span * 0.3)),
        background: token.color,
        cursor: draggable ? 'grab' : onClick ? 'pointer' : 'default',
        touchAction: 'none',
      }}
    >
      {token.image && (
        <img src={token.image} alt="" draggable={false}
          className="pointer-events-none absolute inset-0 h-full w-full rounded-full object-cover" />
      )}
      {frac !== null && (
        <svg className="pointer-events-none absolute inset-0" viewBox="0 0 36 36" aria-hidden>
          <circle cx="18" cy="18" r="17" fill="none" stroke="rgba(0,0,0,0.35)" strokeWidth="2" />
          <circle cx="18" cy="18" r="17" fill="none" stroke={frac > 0.5 ? '#66bb6a' : frac > 0 ? '#ffa726' : '#ef5350'}
            strokeWidth="2.5" strokeDasharray={`${frac * 106.8} 106.8`} transform="rotate(-90 18 18)" strokeLinecap="round" />
        </svg>
      )}
      {!token.image && <span className="z-10 truncate px-0.5">{token.label}</span>}
      {token.conditions.length > 0 && (
        <span className="absolute -right-1 -top-1 z-10 grid h-4 w-4 place-items-center rounded-full bg-danger text-[9px] text-white" title={token.conditions.map((c) => c.name).join(', ')}>
          {token.conditions.length}
        </span>
      )}
    </div>
  );
}
