import { useEffect, useImperativeHandle, useRef } from 'react'
import type { PointerEvent as ReactPointerEvent, Ref } from 'react'

import { cn } from '@/lib/utils'

export type MarkupTool = 'brush' | 'lasso' | 'eraser'

/**
 * Un gesto sobre el lienzo, en píxeles de la foto.
 *
 * `clear` es un gesto más y no un borrado de la lista: así «limpiar todo» se
 * deshace como cualquier otro.
 */
interface Stroke {
  tool: MarkupTool | 'clear'
  /** El color como hex. */
  color: string
  /** Grosor en píxeles de la foto. */
  width: number
  points: [number, number][]
}

/** Lo que el lienzo le cuenta a quien lo usa después de cada gesto. */
export interface MarkupCanvasState {
  canUndo: boolean
  canRedo: boolean
  /** Los colores de los trazos nuevos que siguen a la vista, como hex. */
  colors: string[]
  /** Si un «limpiar todo» dejó afuera la capa con la que se abrió. */
  baseCleared: boolean
}

export interface MarkupCanvasHandle {
  undo: () => void
  redo: () => void
  clear: () => void
  /** La capa entera como PNG, o nada si no quedó nada pintado. */
  exportLayer: () => string | null
  /** Vuelve a la capa guardada y descarta lo que no se guardó. */
  reset: () => void
}

interface Props {
  ref?: Ref<MarkupCanvasHandle>
  /** El tamaño real de la foto: la capa se exporta píxel a píxel con él. */
  width: number
  height: number
  /** La capa guardada, que se dibuja debajo de los trazos nuevos, o nada. */
  base: string | null
  /** Si acepta gestos. Fuera del modo edición el lienzo sólo muestra. */
  active: boolean
  tool: MarkupTool
  /** El color como hex. */
  color: string
  /** El grosor como fracción del ancho de la foto, para que se vea igual en cualquier foto. */
  size: number
  /** El cursor de la herramienta elegida, como valor de CSS. Sólo se usa editando. */
  cursor: string
  label: string
  onChange: (state: MarkupCanvasState) => void
}

/**
 * El lienzo de marcas, del tamaño real de la foto y escalado por CSS.
 *
 * Tiene el tamaño interno de la foto y no el de la pantalla porque lo que se
 * exporta tiene que coincidir píxel a píxel con la foto que el servidor guardó:
 * la máscara se aplica así, y un trazo reescalado caería corrido.
 *
 * Se ve semitransparente, con la misma opacidad que la copia que recibe el
 * modelo: la foto no se tapa, y lo que se ve al dibujar es lo que se manda.
 *
 * Tres herramientas: pincel, lazo con relleno y goma. El relleno es por lazo y
 * no por balde, porque en una foto el balde se derrama por cualquier borde: el
 * pasto y las baldosas no tienen contornos limpios.
 */
export function MarkupCanvas({
  ref,
  width,
  height,
  base,
  active,
  tool,
  color,
  size,
  cursor,
  label,
  onChange,
}: Props) {
  const canvasRef = useRef<HTMLCanvasElement>(null)
  const baseImage = useRef<HTMLImageElement | null>(null)
  const strokes = useRef<Stroke[]>([])
  const undone = useRef<Stroke[]>([])
  const current = useRef<Stroke | null>(null)
  const frame = useRef<number | null>(null)
  // El último `onChange`, para que los gestos no queden atados al del primer dibujado.
  const report = useRef(onChange)
  report.current = onChange

  const render = () => {
    const canvas = canvasRef.current
    const context = canvas?.getContext('2d')
    if (!canvas || !context) return
    context.clearRect(0, 0, canvas.width, canvas.height)
    if (baseImage.current) context.drawImage(baseImage.current, 0, 0, canvas.width, canvas.height)
    for (const stroke of strokes.current) paint(context, stroke, false)
    if (current.current) paint(context, current.current, true)
  }

  const scheduleRender = () => {
    if (frame.current !== null) return
    frame.current = requestAnimationFrame(() => {
      frame.current = null
      render()
    })
  }

  const notify = () => {
    const lastClear = strokes.current.map((stroke) => stroke.tool).lastIndexOf('clear')
    const visible = strokes.current.slice(lastClear + 1)
    report.current({
      canUndo: strokes.current.length > 0,
      canRedo: undone.current.length > 0,
      colors: [
        ...new Set(
          visible
            .filter((stroke) => stroke.tool === 'brush' || stroke.tool === 'lasso')
            .map((stroke) => stroke.color)
        ),
      ],
      baseCleared: lastClear >= 0,
    })
  }

  const loadBase = (source: string | null) => {
    strokes.current = []
    undone.current = []
    current.current = null
    baseImage.current = null
    notify()
    render()
    if (!source) return
    const image = new Image()
    image.onload = () => {
      baseImage.current = image
      render()
    }
    image.src = source
  }

  // La capa guardada cambia al guardar, al sacar las marcas y al ajustar un
  // pedido: en los tres casos lo que se ve tiene que ser exactamente eso.
  useEffect(() => {
    loadBase(base)
    // `loadBase` lee refs y no depende de nada que cambie entre dibujados.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [base, width, height])

  useEffect(
    () => () => {
      if (frame.current !== null) cancelAnimationFrame(frame.current)
    },
    []
  )

  useImperativeHandle(ref, () => ({
    undo: () => {
      const last = strokes.current.pop()
      if (last) undone.current.push(last)
      notify()
      render()
    },
    redo: () => {
      const next = undone.current.pop()
      if (next) strokes.current.push(next)
      notify()
      render()
    },
    clear: () => {
      strokes.current.push({ tool: 'clear', color, width: 0, points: [] })
      undone.current = []
      notify()
      render()
    },
    exportLayer: () => {
      const canvas = canvasRef.current
      const context = canvas?.getContext('2d')
      if (!canvas || !context) return null
      render()
      return hasPaint(context, canvas) ? canvas.toDataURL('image/png') : null
    },
    reset: () => loadBase(base),
  }))

  const pointOf = (event: ReactPointerEvent<HTMLCanvasElement>): [number, number] => {
    const canvas = event.currentTarget
    const box = canvas.getBoundingClientRect()
    return [
      ((event.clientX - box.left) * canvas.width) / box.width,
      ((event.clientY - box.top) * canvas.height) / box.height,
    ]
  }

  const onPointerDown = (event: ReactPointerEvent<HTMLCanvasElement>) => {
    if (!active || event.button !== 0) return
    event.preventDefault()
    event.currentTarget.setPointerCapture(event.pointerId)
    current.current = { tool, color, width: Math.max(2, size * width), points: [pointOf(event)] }
    scheduleRender()
  }

  const onPointerMove = (event: ReactPointerEvent<HTMLCanvasElement>) => {
    const stroke = current.current
    if (!stroke) return
    const point = pointOf(event)
    const [lastX, lastY] = stroke.points[stroke.points.length - 1]
    // Un punto por píxel alcanza; más sólo engorda el trazo sin cambiar cómo se ve.
    if (Math.hypot(point[0] - lastX, point[1] - lastY) < 1.5) return
    stroke.points.push(point)
    scheduleRender()
  }

  const onPointerEnd = () => {
    const stroke = current.current
    current.current = null
    if (!stroke) return
    // Un lazo de menos de tres puntos no encierra nada.
    if (stroke.tool !== 'lasso' || stroke.points.length >= 3) {
      strokes.current.push(stroke)
      undone.current = []
    }
    notify()
    render()
  }

  return (
    <canvas
      ref={canvasRef}
      width={width}
      height={height}
      role="img"
      aria-label={label}
      className={cn(
        'absolute inset-0 size-full rounded-md opacity-60',
        // Con el dedo, dibujar no puede desplazar la página.
        active ? 'touch-none' : 'pointer-events-none'
      )}
      style={active ? { cursor } : undefined}
      onPointerDown={onPointerDown}
      onPointerMove={onPointerMove}
      onPointerUp={onPointerEnd}
      onPointerCancel={onPointerEnd}
    />
  )
}

function paint(context: CanvasRenderingContext2D, stroke: Stroke, inProgress: boolean) {
  if (stroke.tool === 'clear') {
    context.clearRect(0, 0, context.canvas.width, context.canvas.height)
    return
  }
  context.save()
  context.globalCompositeOperation = stroke.tool === 'eraser' ? 'destination-out' : 'source-over'
  context.lineCap = 'round'
  context.lineJoin = 'round'
  context.lineWidth = stroke.width
  context.strokeStyle = stroke.color
  context.fillStyle = stroke.color

  const [first, ...rest] = stroke.points
  context.beginPath()
  context.moveTo(first[0], first[1])
  for (const [x, y] of rest) context.lineTo(x, y)

  if (stroke.tool === 'lasso') {
    // Mientras se dibuja, el lazo se ve como contorno: el relleno llega al
    // soltar, que es cuando se sabe qué encierra.
    if (inProgress) {
      context.lineWidth = Math.max(2, stroke.width / 4)
      context.setLineDash([stroke.width, stroke.width / 2])
      context.stroke()
    } else {
      context.closePath()
      context.fill()
    }
  } else if (rest.length === 0) {
    // Un toque sin arrastre deja un punto, no nada.
    context.arc(first[0], first[1], stroke.width / 2, 0, Math.PI * 2)
    context.fill()
  } else {
    context.stroke()
  }
  context.restore()
}

/** Si quedó algo pintado. Se mira el alfa, que es lo que el servidor también mira. */
function hasPaint(context: CanvasRenderingContext2D, canvas: HTMLCanvasElement): boolean {
  const { data } = context.getImageData(0, 0, canvas.width, canvas.height)
  for (let index = 3; index < data.length; index += 4) {
    if (data[index] !== 0) return true
  }
  return false
}
