import type { BoundingBox, Point, RegionMask, RGBColor } from '@/types/mosaic'
import { colorDistance, relativeLuminance } from './colors'
import { createRegionMask } from './regionMask'

export interface FloodFillOptions {
  tolerance: number
  darkBoundaryThreshold: number
  connectivity: 4 | 8
  occupancy?: Uint8Array
}

export interface FloodFillResult {
  mask: RegionMask
  pixelCount: number
  boundingBox: BoundingBox
  labelPosition: Point
  color: RGBColor
}

const pixelColor = (data: Uint8ClampedArray, index: number): RGBColor => ({
  r: data[index * 4] ?? 0,
  g: data[index * 4 + 1] ?? 0,
  b: data[index * 4 + 2] ?? 0,
})

const median = (values: number[]): number => {
  values.sort((first, second) => first - second)
  return values[Math.floor(values.length / 2)] ?? 0
}

export function floodFillRegion(
  image: ImageData,
  startX: number,
  startY: number,
  options: FloodFillOptions,
): FloodFillResult | null {
  const { width, height, data } = image
  if (startX < 0 || startY < 0 || startX >= width || startY >= height) return null
  const startIndex = startY * width + startX
  if (options.occupancy?.[startIndex]) return null

  const seed = pixelColor(data, startIndex)
  const seedIsDark = relativeLuminance(seed) <= options.darkBoundaryThreshold
  const visited = new Uint8Array(width * height)
  const stack: number[] = [startIndex]
  const runs: number[] = []
  const reds: number[] = []
  const greens: number[] = []
  const blues: number[] = []
  let pixelCount = 0
  let minX = width
  let minY = height
  let maxX = 0
  let maxY = 0

  const isAccepted = (index: number) => {
    if (visited[index] || options.occupancy?.[index]) return false
    const color = pixelColor(data, index)
    if (!seedIsDark && relativeLuminance(color) <= options.darkBoundaryThreshold) return false
    return colorDistance(color, seed) <= options.tolerance
  }

  const sample = (index: number) => {
    const color = pixelColor(data, index)
    if (reds.length < 8000) {
      reds.push(color.r)
      greens.push(color.g)
      blues.push(color.b)
      return
    }
    const replacement = Math.floor(Math.random() * pixelCount)
    if (replacement < 8000) {
      reds[replacement] = color.r
      greens[replacement] = color.g
      blues[replacement] = color.b
    }
  }

  while (stack.length) {
    const index = stack.pop()
    if (index === undefined || !isAccepted(index)) continue
    const y = Math.floor(index / width)
    let left = index % width
    let right = left
    while (left > 0 && isAccepted(y * width + left - 1)) left -= 1
    while (right + 1 < width && isAccepted(y * width + right + 1)) right += 1

    for (let x = left; x <= right; x += 1) {
      const pixelIndex = y * width + x
      visited[pixelIndex] = 1
      pixelCount += 1
      // Avoid both ends of the scanline, where outlines and JPEG artifacts are most likely.
      if (x >= left + 2 && x <= right - 2) sample(pixelIndex)
    }
    runs.push(y, left, right)
    minX = Math.min(minX, left)
    maxX = Math.max(maxX, right)
    minY = Math.min(minY, y)
    maxY = Math.max(maxY, y)

    for (const neighborY of [y - 1, y + 1]) {
      if (neighborY < 0 || neighborY >= height) continue
      const extra = options.connectivity === 8 ? 1 : 0
      const scanStart = Math.max(0, left - extra)
      const scanEnd = Math.min(width - 1, right + extra)
      let insideCandidate = false
      for (let x = scanStart; x <= scanEnd; x += 1) {
        const neighborIndex = neighborY * width + x
        const candidate = isAccepted(neighborIndex)
        if (candidate && !insideCandidate) stack.push(neighborIndex)
        insideCandidate = candidate
      }
    }
  }
  if (!pixelCount) return null
  if (!reds.length) {
    reds.push(seed.r)
    greens.push(seed.g)
    blues.push(seed.b)
  }

  const orderedRuns: Array<[number, number, number]> = []
  for (let index = 0; index < runs.length; index += 3) {
    orderedRuns.push([runs[index] ?? 0, runs[index + 1] ?? 0, runs[index + 2] ?? 0])
  }
  orderedRuns.sort((first, second) => first[0] - second[0] || first[1] - second[1])
  const centerY = (minY + maxY) / 2
  const labelRun = orderedRuns.reduce((best, run) => {
    const score = run[2] - run[1] - Math.abs(run[0] - centerY) * 0.2
    const bestScore = best[2] - best[1] - Math.abs(best[0] - centerY) * 0.2
    return score > bestScore ? run : best
  }, orderedRuns[0] ?? [startY, startX, startX])

  return {
    mask: createRegionMask(width, height, orderedRuns.flatMap((run) => run)),
    pixelCount,
    boundingBox: { x: minX, y: minY, width: maxX - minX + 1, height: maxY - minY + 1 },
    labelPosition: { x: Math.round((labelRun[1] + labelRun[2]) / 2), y: labelRun[0] },
    color: { r: median(reds), g: median(greens), b: median(blues) },
  }
}
