/**
 * Funnel chart using Canvas 2D
 * @countermeasure/web-components/charts/funnel
 */

import { BaseChart } from '../base'
import { defaultColors, hexToRgba, formatNumber } from '../utils'
import { ChartTooltip } from '../tooltip'
import type { FunnelChartConfig, FunnelSegment } from './types'

const LABEL_FONT = '600 12px ui-sans-serif, system-ui, -apple-system, sans-serif'
const VALUE_FONT = '500 11px ui-sans-serif, system-ui, -apple-system, sans-serif'
const HOVER_ALPHA_DELTA = 0.12
const NAMED_COLOR_RGB = new Map<string, [number, number, number]>([
  ['black', [0, 0, 0]],
  ['white', [255, 255, 255]],
  ['navy', [0, 0, 128]],
  ['rebeccapurple', [102, 51, 153]],
  ['transparent', [0, 0, 0]],
])

interface ResolvedSegment {
  label: string
  value: number
  color: string
  /** Width ratio relative to max value (0-1) */
  widthRatio: number
  /** Percentage of first segment value */
  percentage: number
}

/**
 * Pick a readable text foreground/shadow pair for a given segment fill color.
 *
 * Funnel labels sit on top of saturated brand colors that vary segment-to-
 * segment, so we can't just use a single theme-foreground value — light text
 * vanishes on yellow/orange, dark text vanishes on navy/teal. Compute the
 * fill's perceived luminance (sRGB → relative-luminance approximation) and
 * pick white-on-shadow or black-on-shadow accordingly.
 */
function pickReadableText(color: string): { text: string; shadow: string } {
  const rgb = parseColorToRgb(color)
  if (rgb === null) {
    return { text: '#0f172a', shadow: 'rgba(255, 255, 255, 0.45)' }
  }
  const [r, g, b] = rgb
  const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255
  if (luminance > 0.6) {
    return { text: '#0f172a', shadow: 'rgba(255, 255, 255, 0.45)' }
  }
  return { text: '#ffffff', shadow: 'rgba(0, 0, 0, 0.45)' }
}

function parseColorToRgb(color: string): [number, number, number] | null {
  const trimmed = color.trim()
  const parsedLiteral = parseColorLiteralToRgb(trimmed)
  if (parsedLiteral !== null) {
    return parsedLiteral
  }

  const named = NAMED_COLOR_RGB.get(trimmed.toLowerCase())
  if (named !== undefined) {
    return named
  }

  // For anything else — modern CSS syntaxes (oklch, color-mix, lab, color())
  // and the long tail of CSS named colors — round-trip through canvas to get
  // a canonical rgb()/rgba() string we can then parse with our literal parser.
  const normalized = normalizeColorViaCanvas(trimmed)
  if (normalized === null) {
    return null
  }
  return parseColorLiteralToRgb(normalized)
}

function parseColorLiteralToRgb(trimmed: string): [number, number, number] | null {
  if (trimmed.startsWith('#')) {
    const hex = trimmed.slice(1)
    if (hex.length === 3) {
      const r = parseInt(hex[0]! + hex[0], 16)
      const g = parseInt(hex[1]! + hex[1], 16)
      const b = parseInt(hex[2]! + hex[2], 16)
      return [r, g, b]
    }
    if (hex.length === 6) {
      const r = parseInt(hex.slice(0, 2), 16)
      const g = parseInt(hex.slice(2, 4), 16)
      const b = parseInt(hex.slice(4, 6), 16)
      return [r, g, b]
    }
    return null
  }
  const rgbMatch = trimmed.match(/^rgba?\(([^)]+)\)$/i)
  if (rgbMatch) {
    const parts = parseColorFunctionArgs(rgbMatch[1]!)
    const r = parseRgbChannel(parts[0])
    const g = parseRgbChannel(parts[1])
    const b = parseRgbChannel(parts[2])
    if (Number.isFinite(r) && Number.isFinite(g) && Number.isFinite(b)) {
      return [r, g, b]
    }
  }
  const hslMatch = trimmed.match(/^hsla?\(([^)]+)\)$/i)
  if (hslMatch) {
    const parts = parseColorFunctionArgs(hslMatch[1]!)
    const h = parseHue(parts[0])
    const s = parsePercentage(parts[1])
    const l = parsePercentage(parts[2])
    if (Number.isFinite(h) && Number.isFinite(s) && Number.isFinite(l)) {
      return hslToRgb(h, s, l)
    }
  }
  return null
}

// Lazily-allocated 1×1 canvas reused across color normalization calls. The
// canvas itself is stateless w.r.t. fillStyle (each call writes both sentinel
// and the input), so it's safe to share — and re-creating one per call would
// produce a constant stream of small DOM nodes for funnels with many segments
// that re-render on hover.
let normalizeCanvasCtx: CanvasRenderingContext2D | null | undefined

function getNormalizeCanvasCtx(): CanvasRenderingContext2D | null {
  if (normalizeCanvasCtx !== undefined) return normalizeCanvasCtx
  if (typeof document === 'undefined') {
    normalizeCanvasCtx = null
    return null
  }
  const canvas = document.createElement('canvas')
  canvas.width = 1
  canvas.height = 1
  normalizeCanvasCtx = canvas.getContext('2d')
  return normalizeCanvasCtx
}

/**
 * Round-trip an arbitrary CSS color string through a canvas 2D context's
 * `fillStyle` so the browser canonicalizes it to `rgb(...)` or `rgba(...)`.
 * Returns `null` if the color is invalid (canvas keeps the previous value
 * when assignment is rejected — we detect this by comparing against a known
 * sentinel written immediately before the actual color).
 */
function normalizeColorViaCanvas(color: string): string | null {
  const ctx = getNormalizeCanvasCtx()
  if (ctx === null) return null

  // Write a known sentinel and read it back to get its canonical form, then
  // write the candidate color. If canvas rejected `color` as invalid, the
  // post-assignment `fillStyle` will still equal the canonicalized sentinel.
  // Reading between writes also prevents linters from flagging the first
  // write as dead.
  ctx.fillStyle = '#010203'
  const baseline = ctx.fillStyle
  ctx.fillStyle = color
  const normalized = ctx.fillStyle
  if (normalized === baseline && color.trim().toLowerCase() !== '#010203') {
    return null
  }
  return typeof normalized === 'string' ? normalized : null
}

function parseColorFunctionArgs(input: string): string[] {
  const channels = input.split('/')[0]!.trim()
  return channels.includes(',')
    ? channels.split(',').map(part => part.trim())
    : channels.split(/\s+/).map(part => part.trim())
}

function parseRgbChannel(part: string | undefined): number {
  if (part === undefined) return Number.NaN
  if (part.endsWith('%')) {
    const value = Number(part.slice(0, -1))
    return Number.isFinite(value) ? Math.round((value / 100) * 255) : Number.NaN
  }
  return Number(part)
}

function parseHue(part: string | undefined): number {
  if (part === undefined) return Number.NaN
  if (part.endsWith('turn')) {
    const value = Number(part.slice(0, -4))
    return Number.isFinite(value) ? value % 1 : Number.NaN
  }
  if (part.endsWith('rad')) {
    const value = Number(part.slice(0, -3))
    return Number.isFinite(value) ? (value / (Math.PI * 2)) % 1 : Number.NaN
  }
  const raw = part.endsWith('deg') ? part.slice(0, -3) : part
  const value = Number(raw)
  return Number.isFinite(value) ? (((value % 360) + 360) % 360) / 360 : Number.NaN
}

function parsePercentage(part: string | undefined): number {
  if (part === undefined || !part.endsWith('%')) return Number.NaN
  const value = Number(part.slice(0, -1))
  return Number.isFinite(value) ? value / 100 : Number.NaN
}

function hslToRgb(h: number, s: number, l: number): [number, number, number] {
  if (s === 0) {
    const value = Math.round(l * 255)
    return [value, value, value]
  }
  const hue2rgb = (p: number, q: number, t: number): number => {
    let hue = t
    if (hue < 0) hue += 1
    if (hue > 1) hue -= 1
    if (hue < 1 / 6) return p + (q - p) * 6 * hue
    if (hue < 1 / 2) return q
    if (hue < 2 / 3) return p + (q - p) * (2 / 3 - hue) * 6
    return p
  }
  const q = l < 0.5 ? l * (1 + s) : l + s - l * s
  const p = 2 * l - q
  return [
    Math.round(hue2rgb(p, q, h + 1 / 3) * 255),
    Math.round(hue2rgb(p, q, h) * 255),
    Math.round(hue2rgb(p, q, h - 1 / 3) * 255),
  ]
}

export class FunnelChart extends BaseChart {
  private segments: FunnelSegment[]
  private resolved: ResolvedSegment[] = []
  private colors: string[]
  private showLabels: boolean
  private showValues: boolean
  private direction: 'vertical' | 'horizontal'
  private curved: boolean
  private gap: number

  private tooltip: ChartTooltip | null = null
  private hoveredIndex = -1
  private boundMouseMove: (e: MouseEvent) => void
  private boundMouseLeave: () => void

  constructor(config: FunnelChartConfig) {
    super(config)
    this.segments = config.segments
    this.colors = defaultColors()
    this.showLabels = config.showLabels ?? true
    this.showValues = config.showValues ?? true
    this.direction = config.direction ?? 'vertical'
    this.curved = config.curved ?? false
    this.gap = config.gap ?? 2

    if (config.tooltip?.enabled !== false) {
      this.tooltip = new ChartTooltip(this.container)
    }

    this.boundMouseMove = (e: MouseEvent) => {
      this.handleMouseMove(e)
    }
    this.boundMouseLeave = () => {
      this.handleMouseLeave()
    }

    this.canvas.addEventListener('mousemove', this.boundMouseMove)
    this.canvas.addEventListener('mouseleave', this.boundMouseLeave)

    this.resolveSegments()
    this.render()
  }

  /** Replace the segments and re-render */
  update(segments: FunnelSegment[]): void {
    this.segments = segments
    this.resolveSegments()
    this.render()
  }

  render(): void {
    this.clear()

    if (this.resolved.length === 0) return

    if (this.direction === 'horizontal') {
      this.renderHorizontal()
    } else {
      this.renderVertical()
    }
  }

  override destroy(): void {
    this.canvas.removeEventListener('mousemove', this.boundMouseMove)
    this.canvas.removeEventListener('mouseleave', this.boundMouseLeave)
    if (this.tooltip !== null) {
      this.tooltip.destroy()
    }
    super.destroy()
  }

  private resolveSegments(): void {
    const maxVal = this.findMaxValue()
    const firstVal = this.segments[0]?.value ?? 1

    this.resolved = []

    for (let i = 0; i < this.segments.length; i++) {
      const seg = this.segments[i]
      if (seg === undefined) continue

      const color = seg.color ?? this.colors[i % this.colors.length] ?? '#3b82f6'
      const widthRatio = maxVal > 0 ? seg.value / maxVal : 0
      const percentage = firstVal > 0 ? (seg.value / firstVal) * 100 : 0

      this.resolved.push({
        label: seg.label,
        value: seg.value,
        color,
        widthRatio,
        percentage,
      })
    }
  }

  private findMaxValue(): number {
    let max = 0
    for (const seg of this.segments) {
      if (seg.value > max) {
        max = seg.value
      }
    }
    return max
  }

  private renderVertical(): void {
    const plot = this.plotArea
    const count = this.resolved.length
    if (count === 0) return

    const totalGap = this.gap * Math.max(0, count - 1)
    const segmentHeight = (plot.height - totalGap) / count

    for (let i = 0; i < count; i++) {
      const seg = this.resolved[i]
      if (seg === undefined) continue

      const nextSeg = this.resolved[i + 1]
      const currentWidth = seg.widthRatio * plot.width
      const nextWidth = nextSeg !== undefined ? nextSeg.widthRatio * plot.width : currentWidth * 0.7

      const y = plot.y + i * (segmentHeight + this.gap)
      const cx = plot.x + plot.width / 2

      const topLeft = cx - currentWidth / 2
      const topRight = cx + currentWidth / 2
      const bottomLeft = cx - nextWidth / 2
      const bottomRight = cx + nextWidth / 2

      const isHovered = i === this.hoveredIndex
      const fillColor = isHovered ? hexToRgba(seg.color, 1 - HOVER_ALPHA_DELTA) : seg.color

      this.ctx.save()
      this.ctx.beginPath()

      if (this.curved) {
        const cpY = y + segmentHeight * 0.6
        this.ctx.moveTo(topLeft, y)
        this.ctx.lineTo(topRight, y)
        this.ctx.quadraticCurveTo(topRight, cpY, bottomRight, y + segmentHeight)
        this.ctx.lineTo(bottomLeft, y + segmentHeight)
        this.ctx.quadraticCurveTo(topLeft, cpY, topLeft, y)
      } else {
        this.ctx.moveTo(topLeft, y)
        this.ctx.lineTo(topRight, y)
        this.ctx.lineTo(bottomRight, y + segmentHeight)
        this.ctx.lineTo(bottomLeft, y + segmentHeight)
      }

      this.ctx.closePath()
      this.ctx.fillStyle = fillColor
      this.ctx.fill()
      this.ctx.restore()

      // Draw text
      this.drawSegmentText(cx, y + segmentHeight / 2, seg)
    }
  }

  private renderHorizontal(): void {
    const plot = this.plotArea
    const count = this.resolved.length
    if (count === 0) return

    const totalGap = this.gap * Math.max(0, count - 1)
    const segmentWidth = (plot.width - totalGap) / count

    for (let i = 0; i < count; i++) {
      const seg = this.resolved[i]
      if (seg === undefined) continue

      const nextSeg = this.resolved[i + 1]
      const currentHeight = seg.widthRatio * plot.height
      const nextHeight =
        nextSeg !== undefined ? nextSeg.widthRatio * plot.height : currentHeight * 0.7

      const x = plot.x + i * (segmentWidth + this.gap)
      const cy = plot.y + plot.height / 2

      const leftTop = cy - currentHeight / 2
      const leftBottom = cy + currentHeight / 2
      const rightTop = cy - nextHeight / 2
      const rightBottom = cy + nextHeight / 2

      const isHovered = i === this.hoveredIndex
      const fillColor = isHovered ? hexToRgba(seg.color, 1 - HOVER_ALPHA_DELTA) : seg.color

      this.ctx.save()
      this.ctx.beginPath()

      if (this.curved) {
        const cpX = x + segmentWidth * 0.6
        this.ctx.moveTo(x, leftTop)
        this.ctx.lineTo(x, leftBottom)
        this.ctx.quadraticCurveTo(cpX, leftBottom, x + segmentWidth, rightBottom)
        this.ctx.lineTo(x + segmentWidth, rightTop)
        this.ctx.quadraticCurveTo(cpX, leftTop, x, leftTop)
      } else {
        this.ctx.moveTo(x, leftTop)
        this.ctx.lineTo(x, leftBottom)
        this.ctx.lineTo(x + segmentWidth, rightBottom)
        this.ctx.lineTo(x + segmentWidth, rightTop)
      }

      this.ctx.closePath()
      this.ctx.fillStyle = fillColor
      this.ctx.fill()
      this.ctx.restore()

      // Draw text
      this.drawSegmentText(x + segmentWidth / 2, cy, seg)
    }
  }

  private drawSegmentText(cx: number, cy: number, seg: ResolvedSegment): void {
    const { text, shadow } = pickReadableText(seg.color)

    this.ctx.save()
    this.ctx.textAlign = 'center'
    this.ctx.textBaseline = 'middle'
    this.ctx.shadowColor = shadow
    this.ctx.shadowBlur = 4
    this.ctx.shadowOffsetY = 1
    this.ctx.fillStyle = text

    if (this.showLabels && this.showValues) {
      this.ctx.font = LABEL_FONT
      this.ctx.fillText(seg.label, cx, cy - 8)

      this.ctx.font = VALUE_FONT
      this.ctx.globalAlpha = 0.9
      this.ctx.fillText(`${formatNumber(seg.value)} (${seg.percentage.toFixed(1)}%)`, cx, cy + 8)
    } else if (this.showLabels) {
      this.ctx.font = LABEL_FONT
      this.ctx.fillText(seg.label, cx, cy)
    } else if (this.showValues) {
      this.ctx.font = VALUE_FONT
      this.ctx.fillText(formatNumber(seg.value), cx, cy)
    }

    this.ctx.restore()
  }

  private handleMouseMove(e: MouseEvent): void {
    const rect = this.canvas.getBoundingClientRect()
    const mx = e.clientX - rect.left
    const my = e.clientY - rect.top

    const plot = this.plotArea
    const count = this.resolved.length
    if (count === 0) return

    let found = -1

    if (this.direction === 'horizontal') {
      const totalGap = this.gap * Math.max(0, count - 1)
      const segmentWidth = (plot.width - totalGap) / count
      const relX = mx - plot.x

      if (relX >= 0 && relX <= plot.width && my >= plot.y && my <= plot.y + plot.height) {
        for (let i = 0; i < count; i++) {
          const segStart = i * (segmentWidth + this.gap)
          const segEnd = segStart + segmentWidth
          if (relX >= segStart && relX < segEnd) {
            found = i
            break
          }
        }
      }
    } else {
      const totalGap = this.gap * Math.max(0, count - 1)
      const segmentHeight = (plot.height - totalGap) / count
      const relY = my - plot.y

      if (relY >= 0 && relY <= plot.height && mx >= plot.x && mx <= plot.x + plot.width) {
        for (let i = 0; i < count; i++) {
          const segStart = i * (segmentHeight + this.gap)
          const segEnd = segStart + segmentHeight
          if (relY >= segStart && relY < segEnd) {
            found = i
            break
          }
        }
      }
    }

    if (found !== this.hoveredIndex) {
      this.hoveredIndex = found
      this.render()
    }

    if (found !== -1 && this.tooltip !== null) {
      const seg = this.resolved[found]
      if (seg !== undefined) {
        const content = `<strong>${seg.label}</strong><br/>${formatNumber(seg.value)} (${seg.percentage.toFixed(1)}%)`
        this.tooltip.show(mx, my, content)
      }
    } else if (this.tooltip !== null) {
      this.tooltip.hide()
    }
  }

  private handleMouseLeave(): void {
    if (this.hoveredIndex !== -1) {
      this.hoveredIndex = -1
      if (this.tooltip !== null) {
        this.tooltip.hide()
      }
      this.render()
    }
  }
}
