/**
 * Gauge chart using Canvas 2D
 * @countermeasure/web-components/charts/gauge
 */

import { BaseChart } from '../base'
import { defaultColors, clamp, formatNumber, fitText } from '../utils'
import type { GaugeChartConfig, GaugeThreshold } from './types'

const DEG_TO_RAD = Math.PI / 180

const FONT_STACK = 'ui-sans-serif, system-ui, -apple-system, sans-serif'
const MINMAX_FONT = `500 11px ${FONT_STACK}`

// Shared alpha constants — used both for the resolved-token hsl(...) path and
// the literal FALLBACK colors below so the rendered translucency matches
// regardless of whether theme tokens are present.
const TRACK_ALPHA = 0.18
const TRACK_INNER_ALPHA = 0.08
const NEEDLE_SOFT_ALPHA = 0.3
const SHADOW_ALPHA = 0.25

const FALLBACK = {
  track: `rgba(148, 163, 184, ${String(TRACK_ALPHA)})`,
  trackInner: `rgba(148, 163, 184, ${String(TRACK_INNER_ALPHA)})`,
  foreground: '#0f172a',
  muted: '#64748b',
  needle: '#0f172a',
  needleSoft: `rgba(15, 23, 42, ${String(NEEDLE_SOFT_ALPHA)})`,
  fill: '#3b82f6',
  shadow: `rgba(15, 23, 42, ${String(SHADOW_ALPHA)})`,
}
const DEFAULT_FILL = defaultColors()[0] ?? FALLBACK.fill

// Hard cap on full-turn additions so a pathological caller (e.g. start=NaN or
// `Number.MAX_SAFE_INTEGER`) can never spin the normalization loop forever. In
// practice angles are within a turn or two; 32 turns is far more than needed
// while still being a tight finite bound.
const MAX_ANGLE_NORMALIZE_TURNS = 32

export class GaugeChart extends BaseChart {
  private value: number
  private min: number
  private max: number
  private label: string | undefined
  private unit: string | undefined
  private thresholds: GaugeThreshold[]
  private arcWidth: number
  private startAngle: number
  private endAngle: number
  private showValue: boolean

  constructor(config: GaugeChartConfig) {
    super(config)
    this.value = config.value
    this.min = config.min ?? 0
    this.max = config.max ?? 100
    this.label = config.label
    this.unit = config.unit
    this.thresholds = config.thresholds ?? []
    this.arcWidth = config.arcWidth ?? 20
    this.startAngle = config.startAngle ?? 135
    this.endAngle = config.endAngle ?? 405
    // Normalize: `endAngle` must produce a positive sweep. If a consumer passes
    // an `endAngle <= startAngle`, advance it by full turns so Canvas draws
    // the intended arc rather than wrapping the wrong way around — even when
    // the consumer-supplied values are more than one turn apart.
    this.normalizeAngles()
    this.showValue = config.showValue ?? true

    this.render()
  }

  /** Update just the current value and re-render */
  updateValue(value: number): void {
    this.value = value
    this.render()
  }

  /** Update the full configuration and re-render */
  updateConfig(config: Partial<GaugeChartConfig>): void {
    if (config.value !== undefined) {
      this.value = config.value
    }
    if (config.min !== undefined) {
      this.min = config.min
    }
    if (config.max !== undefined) {
      this.max = config.max
    }
    if (config.label !== undefined) {
      this.label = config.label
    }
    if (config.unit !== undefined) {
      this.unit = config.unit
    }
    if (config.thresholds !== undefined) {
      this.thresholds = config.thresholds
    }
    if (config.arcWidth !== undefined) {
      this.arcWidth = config.arcWidth
    }
    if (config.startAngle !== undefined) {
      this.startAngle = config.startAngle
    }
    if (config.endAngle !== undefined) {
      this.endAngle = config.endAngle
    }
    // Re-normalize after either angle changes so consumers updating one without
    // the other can't accidentally invert the sweep — and so that inputs more
    // than one full turn apart still resolve to a positive sweep.
    if (config.startAngle !== undefined || config.endAngle !== undefined) {
      this.normalizeAngles()
    }
    if (config.showValue !== undefined) {
      this.showValue = config.showValue
    }
    this.render()
  }

  /**
   * Advance `endAngle` by whole 360° turns until it exceeds `startAngle`, so
   * Canvas always draws a positive sweep regardless of how the consumer
   * supplied the angles. Capped at `MAX_ANGLE_NORMALIZE_TURNS` iterations to
   * guarantee termination on pathological inputs (NaN, ±Infinity, very large
   * negative deltas).
   */
  private normalizeAngles(): void {
    for (let i = 0; i < MAX_ANGLE_NORMALIZE_TURNS && this.endAngle <= this.startAngle; i++) {
      this.endAngle += 360
    }
  }

  /**
   * Resolve a tonal palette from the container's computed styles so the chart
   * follows the active theme (light / dark / monokai / glass). Falls back to
   * neutral light-mode values when the chart isn't attached to the document
   * or when the variable is unset.
   */
  private resolvePalette(): typeof FALLBACK {
    const target = this.container
    // `ownerDocument` is non-nullable on HTMLElement — detached elements still
    // carry their original document. `isConnected` is the right signal for
    // "actually in a rendered tree where getComputedStyle is meaningful".
    if (!(target instanceof HTMLElement) || !target.isConnected) {
      return FALLBACK
    }
    const cs = target.ownerDocument.defaultView?.getComputedStyle(target)
    if (cs === undefined) {
      return FALLBACK
    }

    // CSS tokens in this codebase frequently chain through `var(...)` aliases
    // (e.g. `--muted-foreground: var(--foreground-secondary)`), so we have to
    // follow the chain until we reach a literal value before composing
    // `hsl(...)`. Computed-style on a single property will return the literal
    // text — including the `var(...)` reference — when the chain isn't resolved
    // at the matched rule.
    const MAX_DEPTH = 5
    const VAR_RE = /^var\(\s*(--[\w-]+)\s*(?:,\s*([^)]+))?\)\s*$/
    const resolveVar = (name: string, depth = 0): string => {
      if (depth >= MAX_DEPTH) return ''
      const raw = cs.getPropertyValue(name).trim()
      if (raw === '') return ''
      const match = VAR_RE.exec(raw)
      if (match !== null) {
        const ref = match[1]
        const fallback = match[2]?.trim() ?? ''
        if (ref !== undefined) {
          const next = resolveVar(ref, depth + 1)
          if (next !== '') return next
        }
        return fallback
      }
      return raw
    }

    const hsl = (name: string, fallback: string, alpha = 1): string => {
      const raw = resolveVar(name)
      if (raw === '') return fallback
      // If the resolved token is already a full color (hex, rgb(), hsl(),
      // named color, etc.) pass it through unchanged so themes can override
      // tokens with literal colors without producing invalid `hsl(#abcdef)`.
      if (
        raw.startsWith('#') ||
        raw.startsWith('rgb(') ||
        raw.startsWith('rgba(') ||
        raw.startsWith('hsl(') ||
        raw.startsWith('hsla(') ||
        raw.startsWith('oklch(') ||
        raw.startsWith('oklab(') ||
        raw.startsWith('color(')
      ) {
        // We can't reliably re-apply alpha to an arbitrary color string from
        // Canvas without parsing it — for those themes we trade the alpha
        // (track translucency) for correctness. The fallback alpha values are
        // applied only when the token resolves to space-separated HSL parts.
        return raw
      }
      return alpha === 1 ? `hsl(${raw})` : `hsl(${raw} / ${alpha.toString()})`
    }

    const fg = hsl('--foreground', FALLBACK.foreground)
    return {
      track: hsl('--muted-foreground', FALLBACK.track, TRACK_ALPHA),
      trackInner: hsl('--muted-foreground', FALLBACK.trackInner, TRACK_INNER_ALPHA),
      foreground: fg,
      muted: hsl('--muted-foreground', FALLBACK.muted),
      needle: fg,
      needleSoft: hsl('--foreground', FALLBACK.needleSoft, NEEDLE_SOFT_ALPHA),
      fill: DEFAULT_FILL,
      shadow: hsl('--foreground', FALLBACK.shadow, SHADOW_ALPHA),
    }
  }

  render(): void {
    this.clear()

    const palette = this.resolvePalette()
    const plot = this.plotArea
    const cx = plot.x + plot.width / 2
    const cy = plot.y + plot.height * 0.58
    const radius = Math.min(plot.width / 2, plot.height * 0.55) - this.arcWidth / 2

    const startRad = this.startAngle * DEG_TO_RAD
    const endRad = this.endAngle * DEG_TO_RAD
    const totalSweep = endRad - startRad

    // Track — a thin inner ring then the main track on top for a subtle inset
    this.ctx.save()
    this.ctx.beginPath()
    this.ctx.arc(cx, cy, radius, startRad, endRad)
    this.ctx.strokeStyle = palette.trackInner
    this.ctx.lineWidth = this.arcWidth + 4
    this.ctx.lineCap = 'round'
    this.ctx.stroke()
    this.ctx.restore()

    this.ctx.save()
    this.ctx.beginPath()
    this.ctx.arc(cx, cy, radius, startRad, endRad)
    this.ctx.strokeStyle = palette.track
    this.ctx.lineWidth = this.arcWidth
    this.ctx.lineCap = 'round'
    this.ctx.stroke()
    this.ctx.restore()

    // Active fill — drawn over the track. Thresholds layer below, fill arc on top.
    if (this.thresholds.length > 0) {
      this.drawThresholdArcs(cx, cy, radius, startRad, totalSweep, palette)
    } else {
      this.drawFillArc(cx, cy, radius, startRad, totalSweep, palette)
    }

    // Needle floats above the dial — keep it subtle so it doesn't obscure the value
    this.drawNeedle(cx, cy, radius, startRad, totalSweep, palette)

    // Centered value + label sit in the dial's open core
    if (this.showValue) {
      this.drawCenterText(cx, cy, radius, palette)
    }

    this.drawMinMaxLabels(cx, cy, radius, startRad, endRad, palette)
  }

  override destroy(): void {
    super.destroy()
  }

  private drawThresholdArcs(
    cx: number,
    cy: number,
    radius: number,
    startRad: number,
    totalSweep: number,
    palette: typeof FALLBACK
  ): void {
    const sorted = [...this.thresholds].sort((a, b) => a.value - b.value)
    const range = this.max - this.min
    if (range <= 0) return

    const clampedValue = clamp(this.value, this.min, this.max)
    const valueRatio = (clampedValue - this.min) / range
    const fillEnd = startRad + valueRatio * totalSweep

    // First pass: build the list of visible segments. We need to know how many
    // segments will actually be drawn before we render so that only the first
    // gets a rounded start-cap and only the last (the active head) gets a
    // rounded end-cap; everything in between is `butt` to prevent overlapping
    // semicircular caps from bleeding into adjacent threshold colors.
    interface Segment {
      start: number
      end: number
      color: string
    }
    const segments: Segment[] = []
    let prevRatio = 0

    for (const threshold of sorted) {
      const thresholdRatio = clamp((threshold.value - this.min) / range, 0, 1)
      const segStart = startRad + prevRatio * totalSweep
      const segEnd = startRad + thresholdRatio * totalSweep
      const drawEnd = Math.min(segEnd, fillEnd)

      if (drawEnd > segStart) {
        segments.push({ start: segStart, end: drawEnd, color: threshold.color })
      }
      prevRatio = thresholdRatio
    }

    // Overflow tail: value extends past the highest threshold — extend the
    // last threshold's color out to the value ratio.
    if (prevRatio < valueRatio) {
      const lastColor = sorted[sorted.length - 1]?.color ?? palette.fill
      const segStart = startRad + prevRatio * totalSweep
      segments.push({ start: segStart, end: fillEnd, color: lastColor })
    }

    if (segments.length === 0) return

    const lastIdx = segments.length - 1
    // Single visible segment: keep the simple rounded sweep — no junctions to
    // worry about so both caps get the soft rounded edge.
    if (lastIdx === 0) {
      const only = segments[0]
      if (only !== undefined) {
        this.drawCapArc(cx, cy, radius, only.start, only.end, only.color, palette)
      }
      return
    }

    // Multi-segment: render every segment with `butt` caps so adjacent
    // threshold colors don't tint each other through overlapping rounded
    // semicircles at the junctions, then add tiny `round`-capped sub-arcs at
    // the outer head and tail so the active sweep's outermost edges stay soft.
    for (const seg of segments) {
      this.ctx.save()
      this.ctx.shadowColor = palette.shadow
      this.ctx.shadowBlur = 6
      this.ctx.shadowOffsetY = 1
      this.ctx.beginPath()
      this.ctx.arc(cx, cy, radius, seg.start, seg.end)
      this.ctx.strokeStyle = seg.color
      this.ctx.lineWidth = this.arcWidth
      this.ctx.lineCap = 'butt'
      this.ctx.stroke()
      this.ctx.restore()
    }

    const head = segments[0]
    const tail = segments[lastIdx]
    if (head !== undefined) {
      const capLen = Math.min(0.001, head.end - head.start)
      if (capLen > 0) {
        this.drawCapArc(cx, cy, radius, head.start, head.start + capLen, head.color, palette)
      }
    }
    if (tail !== undefined) {
      const capLen = Math.min(0.001, tail.end - tail.start)
      if (capLen > 0) {
        this.drawCapArc(cx, cy, radius, tail.end - capLen, tail.end, tail.color, palette)
      }
    }
  }

  private drawCapArc(
    cx: number,
    cy: number,
    radius: number,
    start: number,
    end: number,
    color: string,
    palette: typeof FALLBACK
  ): void {
    this.ctx.save()
    this.ctx.shadowColor = palette.shadow
    this.ctx.shadowBlur = 6
    this.ctx.shadowOffsetY = 1
    this.ctx.beginPath()
    this.ctx.arc(cx, cy, radius, start, end)
    this.ctx.strokeStyle = color
    this.ctx.lineWidth = this.arcWidth
    this.ctx.lineCap = 'round'
    this.ctx.stroke()
    this.ctx.restore()
  }

  private drawFillArc(
    cx: number,
    cy: number,
    radius: number,
    startRad: number,
    totalSweep: number,
    palette: typeof FALLBACK
  ): void {
    const range = this.max - this.min
    if (range <= 0) return

    const clampedValue = clamp(this.value, this.min, this.max)
    const ratio = (clampedValue - this.min) / range
    const fillEnd = startRad + ratio * totalSweep

    this.ctx.save()
    this.ctx.shadowColor = palette.shadow
    this.ctx.shadowBlur = 8
    this.ctx.shadowOffsetY = 2
    this.ctx.beginPath()
    this.ctx.arc(cx, cy, radius, startRad, fillEnd)
    this.ctx.strokeStyle = palette.fill
    this.ctx.lineWidth = this.arcWidth
    this.ctx.lineCap = 'round'
    this.ctx.stroke()
    this.ctx.restore()
  }

  private drawNeedle(
    cx: number,
    cy: number,
    radius: number,
    startRad: number,
    totalSweep: number,
    palette: typeof FALLBACK
  ): void {
    const range = this.max - this.min
    if (range <= 0) return

    const clampedValue = clamp(this.value, this.min, this.max)
    const ratio = (clampedValue - this.min) / range
    const needleAngle = startRad + ratio * totalSweep
    const needleLength = Math.max(4, radius - this.arcWidth - 2)

    const tipX = cx + Math.cos(needleAngle) * needleLength
    const tipY = cy + Math.sin(needleAngle) * needleLength

    this.ctx.save()

    // Faint trail so the needle reads at a glance
    this.ctx.beginPath()
    this.ctx.moveTo(cx, cy)
    this.ctx.lineTo(tipX, tipY)
    this.ctx.strokeStyle = palette.needleSoft
    this.ctx.lineWidth = 3
    this.ctx.lineCap = 'round'
    this.ctx.stroke()

    // Crisp tip
    this.ctx.beginPath()
    this.ctx.moveTo(cx, cy)
    this.ctx.lineTo(tipX, tipY)
    this.ctx.strokeStyle = palette.needle
    this.ctx.lineWidth = 1.5
    this.ctx.lineCap = 'round'
    this.ctx.stroke()

    // Pivot — concentric dots so the centerpiece reads in any theme
    this.ctx.beginPath()
    this.ctx.arc(cx, cy, 6, 0, Math.PI * 2)
    this.ctx.fillStyle = palette.needle
    this.ctx.fill()
    this.ctx.beginPath()
    this.ctx.arc(cx, cy, 2.5, 0, Math.PI * 2)
    this.ctx.fillStyle = palette.fill
    this.ctx.fill()

    this.ctx.restore()
  }

  private drawCenterText(cx: number, cy: number, radius: number, palette: typeof FALLBACK): void {
    this.ctx.save()
    this.ctx.textAlign = 'center'
    this.ctx.textBaseline = 'middle'

    // Scale the value/label text to the dial size so small gauges don't
    // overflow the arc. The factors are tuned so a full-size gauge keeps the
    // original 32px value / 13px label.
    const valuePx = clamp(Math.round(radius * 0.42), 13, 32)
    const labelPx = clamp(Math.round(radius * 0.16), 9, 13)
    const scale = valuePx / 32
    const hasLabel = this.label !== undefined
    const valueY = hasLabel ? cy - Math.round(14 * scale) : cy - Math.round(4 * scale)
    const labelY = cy + Math.round(16 * scale)

    const valueText =
      this.unit !== undefined ? `${formatNumber(this.value)}${this.unit}` : formatNumber(this.value)

    this.ctx.fillStyle = palette.foreground
    this.ctx.font = `700 ${valuePx}px ${FONT_STACK}`
    this.ctx.fillText(valueText, cx, valueY)

    if (this.label !== undefined) {
      this.ctx.fillStyle = palette.muted
      this.ctx.font = `500 ${labelPx}px ${FONT_STACK}`
      // Keep the label within the dial core; truncate with an ellipsis if long.
      const labelText = fitText(this.label, radius * 1.7, t => this.ctx.measureText(t).width)
      this.ctx.fillText(labelText, cx, labelY)
    }

    this.ctx.restore()
  }

  private drawMinMaxLabels(
    cx: number,
    cy: number,
    radius: number,
    startRad: number,
    endRad: number,
    palette: typeof FALLBACK
  ): void {
    this.ctx.save()
    this.ctx.fillStyle = palette.muted
    this.ctx.font = MINMAX_FONT
    this.ctx.textAlign = 'center'
    this.ctx.textBaseline = 'middle'

    const labelDistance = radius + this.arcWidth / 2 + 14
    const minX = cx + Math.cos(startRad) * labelDistance
    const minY = cy + Math.sin(startRad) * labelDistance
    this.ctx.fillText(formatNumber(this.min), minX, minY)

    const maxX = cx + Math.cos(endRad) * labelDistance
    const maxY = cy + Math.sin(endRad) * labelDistance
    this.ctx.fillText(formatNumber(this.max), maxX, maxY)

    this.ctx.restore()
  }
}
