/**
 * Sankey diagram - flow visualization with nodes and curved links
 * @countermeasure/web-components/charts/sankey
 */

import { BaseChart } from '../base'
import type { SankeyChartConfig, SankeyNode, SankeyLink } from './types'
import { defaultColors, hexToRgba, formatNumber } from '../utils'
import { ChartTooltip } from '../tooltip'

const DEFAULT_NODE_WIDTH = 20
const DEFAULT_NODE_PADDING = 10
const DEFAULT_LINK_OPACITY = 0.4
const LABEL_FONT = '11px system-ui, sans-serif'
const LABEL_COLOR = '#334155'

interface ComputedNode {
  id: string
  label: string
  color: string
  x: number
  y: number
  width: number
  height: number
  column: number
  value: number
  sourceLinks: ComputedLink[]
  targetLinks: ComputedLink[]
}

interface ComputedLink {
  source: ComputedNode
  target: ComputedNode
  value: number
  color: string
  sourceY: number
  targetY: number
  width: number
}

export class SankeyChart extends BaseChart {
  private nodes: SankeyNode[]
  private links: SankeyLink[]
  private nodeWidth: number
  private nodePadding: number
  private linkOpacity: number
  private tooltipEnabled: boolean
  private tooltipFormat: ((value: number, label: string, series: string) => string) | null
  private tooltip: ChartTooltip | null
  private computedNodes: ComputedNode[]
  private computedLinks: ComputedLink[]
  private colors: string[]
  private handleMouseMove: ((e: MouseEvent) => void) | null
  private handleMouseLeave: (() => void) | null
  private hoveredNodeId: string | null
  private hoveredLinkIndex: number

  constructor(config: SankeyChartConfig) {
    super(config)

    this.nodes = config.nodes
    this.links = config.links
    this.nodeWidth = config.nodeWidth ?? DEFAULT_NODE_WIDTH
    this.nodePadding = config.nodePadding ?? DEFAULT_NODE_PADDING
    this.linkOpacity = config.linkOpacity ?? DEFAULT_LINK_OPACITY
    this.tooltipEnabled = config.tooltip?.enabled !== false
    this.tooltipFormat = config.tooltip?.format ?? null
    this.tooltip = null
    this.computedNodes = []
    this.computedLinks = []
    this.colors = defaultColors()
    this.handleMouseMove = null
    this.handleMouseLeave = null
    this.hoveredNodeId = null
    this.hoveredLinkIndex = -1

    if (this.tooltipEnabled) {
      this.setupTooltip()
    }

    this.render()
  }

  private computeLayout(): void {
    const plot = this.plotArea

    // Build adjacency and compute columns via topological ordering
    const nodeMap = new Map<string, ComputedNode>()
    for (let i = 0; i < this.nodes.length; i++) {
      const node = this.nodes[i]
      if (node === undefined) continue
      nodeMap.set(node.id, {
        id: node.id,
        label: node.label,
        color: node.color ?? this.colors[i % this.colors.length] ?? '#3b82f6',
        x: 0,
        y: 0,
        width: this.nodeWidth,
        height: 0,
        column: 0,
        value: 0,
        sourceLinks: [],
        targetLinks: [],
      })
    }

    // Compute columns: depth from sources
    const incomingCounts = new Map<string, number>()
    for (const node of this.nodes) {
      incomingCounts.set(node.id, 0)
    }
    for (const link of this.links) {
      const current = incomingCounts.get(link.target)
      if (current !== undefined) {
        incomingCounts.set(link.target, current + 1)
      }
    }

    // BFS to assign columns
    const visited = new Set<string>()
    const queue: { id: string; col: number }[] = []

    for (const [id, count] of incomingCounts) {
      if (count === 0) {
        queue.push({ id, col: 0 })
      }
    }

    // If no sources found, start from first node
    if (queue.length === 0 && this.nodes.length > 0) {
      const firstNode = this.nodes[0]
      if (firstNode !== undefined) {
        queue.push({ id: firstNode.id, col: 0 })
      }
    }

    let maxCol = 0
    while (queue.length > 0) {
      const item = queue.shift()
      if (item === undefined) continue

      if (visited.has(item.id)) continue
      visited.add(item.id)

      const node = nodeMap.get(item.id)
      if (node !== undefined) {
        node.column = Math.max(node.column, item.col)
        if (node.column > maxCol) maxCol = node.column
      }

      // Find outgoing links
      for (const link of this.links) {
        if (link.source === item.id && !visited.has(link.target)) {
          const targetNode = nodeMap.get(link.target)
          if (targetNode !== undefined) {
            targetNode.column = Math.max(targetNode.column, item.col + 1)
          }
          queue.push({ id: link.target, col: item.col + 1 })
        }
      }
    }

    // Update maxCol after BFS
    for (const node of nodeMap.values()) {
      if (node.column > maxCol) maxCol = node.column
    }

    // Compute node values (max of incoming/outgoing link totals)
    for (const link of this.links) {
      const sourceNode = nodeMap.get(link.source)
      const targetNode = nodeMap.get(link.target)
      if (sourceNode !== undefined) {
        sourceNode.value += link.value
      }
      if (targetNode !== undefined) {
        targetNode.value += link.value
      }
    }

    // For nodes with both incoming and outgoing, value = max flow side
    // The above double-counts, so we recalculate as max(outgoing, incoming)
    for (const node of nodeMap.values()) {
      let outgoing = 0
      let incoming = 0
      for (const link of this.links) {
        if (link.source === node.id) outgoing += link.value
        if (link.target === node.id) incoming += link.value
      }
      node.value = Math.max(outgoing, incoming)
    }

    // Position nodes horizontally by column
    const columnWidth = maxCol > 0 ? (plot.width - this.nodeWidth) / maxCol : 0

    // Group nodes by column
    const columns = new Map<number, ComputedNode[]>()
    for (const node of nodeMap.values()) {
      node.x = plot.x + node.column * columnWidth

      const col = columns.get(node.column)
      if (col !== undefined) {
        col.push(node)
      } else {
        columns.set(node.column, [node])
      }
    }

    // Position nodes vertically within each column
    for (const colNodes of columns.values()) {
      const totalValue = colNodes.reduce((sum, n) => sum + n.value, 0)
      const totalPadding = this.nodePadding * Math.max(0, colNodes.length - 1)
      const availableHeight = plot.height - totalPadding

      let currentY = plot.y
      for (const node of colNodes) {
        const nodeHeight =
          totalValue > 0
            ? (node.value / totalValue) * availableHeight
            : availableHeight / colNodes.length
        node.height = Math.max(1, nodeHeight)
        node.y = currentY
        currentY += node.height + this.nodePadding
      }
    }

    // Build computed links
    this.computedLinks = []

    // Track the current y-offset for source and target connection points
    const sourceOffsets = new Map<string, number>()
    const targetOffsets = new Map<string, number>()

    for (const link of this.links) {
      const source = nodeMap.get(link.source)
      const target = nodeMap.get(link.target)
      if (source === undefined || target === undefined) continue

      const linkWidth = source.value > 0 ? (link.value / source.value) * source.height : 0

      const sourceOffset = sourceOffsets.get(source.id) ?? 0
      const targetOffset = targetOffsets.get(target.id) ?? 0

      const computed: ComputedLink = {
        source,
        target,
        value: link.value,
        color: link.color ?? source.color,
        sourceY: source.y + sourceOffset + linkWidth / 2,
        targetY: target.y + targetOffset + linkWidth / 2,
        width: linkWidth,
      }

      sourceOffsets.set(source.id, sourceOffset + linkWidth)
      targetOffsets.set(target.id, targetOffset + linkWidth)

      computed.source.sourceLinks.push(computed)
      computed.target.targetLinks.push(computed)
      this.computedLinks.push(computed)
    }

    this.computedNodes = [...nodeMap.values()]
  }

  private setupTooltip(): void {
    this.tooltip = new ChartTooltip(this.container)

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

      // Check nodes
      let foundNodeId: string | null = null
      for (const node of this.computedNodes) {
        if (
          mx >= node.x &&
          mx <= node.x + node.width &&
          my >= node.y &&
          my <= node.y + node.height
        ) {
          foundNodeId = node.id
          break
        }
      }

      // Check links if no node hit
      let foundLinkIndex = -1
      if (foundNodeId === null) {
        for (let i = 0; i < this.computedLinks.length; i++) {
          const link = this.computedLinks[i]
          if (link === undefined) continue
          if (this.isPointOnLink(mx, my, link)) {
            foundLinkIndex = i
            break
          }
        }
      }

      const needsRedraw =
        foundNodeId !== this.hoveredNodeId || foundLinkIndex !== this.hoveredLinkIndex

      this.hoveredNodeId = foundNodeId
      this.hoveredLinkIndex = foundLinkIndex

      if (needsRedraw) {
        this.render()
      }

      if (foundNodeId !== null) {
        const node = this.computedNodes.find(n => n.id === foundNodeId)
        if (node !== undefined) {
          let content: string
          if (this.tooltipFormat !== null) {
            content = this.tooltipFormat(node.value, node.label, '')
          } else {
            content = `<strong>${node.label}</strong><br/>Total: ${formatNumber(node.value)}`
          }
          this.tooltip?.show(mx, my, content)
        }
      } else if (foundLinkIndex >= 0) {
        const link = this.computedLinks[foundLinkIndex]
        if (link !== undefined) {
          let content: string
          if (this.tooltipFormat !== null) {
            content = this.tooltipFormat(link.value, link.source.label, link.target.label)
          } else {
            content = `<strong>${link.source.label} &rarr; ${link.target.label}</strong><br/>Value: ${formatNumber(link.value)}`
          }
          this.tooltip?.show(mx, my, content)
        }
      } else {
        this.tooltip?.hide()
      }
    }

    this.handleMouseLeave = (): void => {
      if (this.hoveredNodeId !== null || this.hoveredLinkIndex !== -1) {
        this.hoveredNodeId = null
        this.hoveredLinkIndex = -1
        this.render()
      }
      this.tooltip?.hide()
    }

    this.canvas.addEventListener('mousemove', this.handleMouseMove)
    this.canvas.addEventListener('mouseleave', this.handleMouseLeave)
  }

  private isPointOnLink(mx: number, my: number, link: ComputedLink): boolean {
    const sx = link.source.x + link.source.width
    const tx = link.target.x
    const halfWidth = link.width / 2

    // Quick bounding box check
    if (mx < sx || mx > tx) return false

    // Compute the bezier center Y at this x position
    const cpx1 = sx + (tx - sx) / 3
    const cpx2 = tx - (tx - sx) / 3
    const bezierT = this.solveBezierT(sx, cpx1, cpx2, tx, mx)
    const centerY = this.cubicBezier(
      link.sourceY,
      link.sourceY,
      link.targetY,
      link.targetY,
      bezierT
    )

    return Math.abs(my - centerY) <= halfWidth + 4
  }

  private solveBezierT(
    _p0: number,
    _p1: number,
    _p2: number,
    _p3: number,
    _target: number
  ): number {
    // For horizontal bezier, t approximates linearly since x is monotonic
    const x0 = _p0
    const x3 = _p3
    if (x3 === x0) return 0
    return (_target - x0) / (x3 - x0)
  }

  private cubicBezier(p0: number, p1: number, p2: number, p3: number, t: number): number {
    const mt = 1 - t
    return mt * mt * mt * p0 + 3 * mt * mt * t * p1 + 3 * mt * t * t * p2 + t * t * t * p3
  }

  public render(): void {
    this.clear()
    this.computeLayout()

    const { ctx } = this

    // Draw links
    ctx.save()
    for (let i = 0; i < this.computedLinks.length; i++) {
      const link = this.computedLinks[i]
      if (link === undefined) continue

      const isHighlightedByNode =
        this.hoveredNodeId !== null &&
        (link.source.id === this.hoveredNodeId || link.target.id === this.hoveredNodeId)
      const isHighlightedLink = i === this.hoveredLinkIndex

      const opacity =
        isHighlightedByNode || isHighlightedLink
          ? Math.min(this.linkOpacity * 2, 0.9)
          : this.linkOpacity

      const sx = link.source.x + link.source.width
      const sy = link.sourceY
      const tx = link.target.x
      const ty = link.targetY
      const cpx1 = sx + (tx - sx) * 0.4
      const cpx2 = tx - (tx - sx) * 0.4

      ctx.beginPath()
      ctx.moveTo(sx, sy - link.width / 2)
      ctx.bezierCurveTo(
        cpx1,
        sy - link.width / 2,
        cpx2,
        ty - link.width / 2,
        tx,
        ty - link.width / 2
      )
      ctx.lineTo(tx, ty + link.width / 2)
      ctx.bezierCurveTo(
        cpx2,
        ty + link.width / 2,
        cpx1,
        sy + link.width / 2,
        sx,
        sy + link.width / 2
      )
      ctx.closePath()

      ctx.fillStyle = hexToRgba(link.color, opacity)
      ctx.fill()
    }
    ctx.restore()

    // Draw nodes
    ctx.save()
    for (const node of this.computedNodes) {
      const isHovered = node.id === this.hoveredNodeId

      ctx.fillStyle = isHovered ? hexToRgba(node.color, 0.85) : node.color
      ctx.fillRect(node.x, node.y, node.width, node.height)

      if (isHovered) {
        ctx.strokeStyle = '#1e293b'
        ctx.lineWidth = 2
        ctx.strokeRect(node.x, node.y, node.width, node.height)
      }

      // Draw label
      ctx.fillStyle = LABEL_COLOR
      ctx.font = LABEL_FONT
      ctx.textBaseline = 'middle'

      const labelY = node.y + node.height / 2

      // Position label based on column
      if (node.column === 0) {
        ctx.textAlign = 'right'
        ctx.fillText(node.label, node.x - 6, labelY)
      } else {
        ctx.textAlign = 'left'
        ctx.fillText(node.label, node.x + node.width + 6, labelY)
      }
    }
    ctx.restore()
  }

  public update(nodes: SankeyNode[], links: SankeyLink[]): void {
    this.nodes = nodes
    this.links = links
    this.hoveredNodeId = null
    this.hoveredLinkIndex = -1
    this.render()
  }

  public override destroy(): void {
    if (this.handleMouseMove !== null) {
      this.canvas.removeEventListener('mousemove', this.handleMouseMove)
    }
    if (this.handleMouseLeave !== null) {
      this.canvas.removeEventListener('mouseleave', this.handleMouseLeave)
    }
    this.tooltip?.destroy()
    super.destroy()
  }
}
