/**
 * FlowDiagram - SVG-based flow diagram with auto-layout and interactive zoom/pan
 * @countermeasure/web-components/visualization/flow-diagram
 */

import type { FlowDiagramConfig, FlowEdge, FlowNode } from './types'
import { resolveContainer, uniqueId } from '../../primitives/utils'

export type { FlowDiagramConfig, FlowEdge, FlowNode } from './types'

const SVG_NS = 'http://www.w3.org/2000/svg'

const DEFAULT_NODE_WIDTH = 140
const DEFAULT_NODE_HEIGHT = 50
const LAYER_GAP_TB = 100
const LAYER_GAP_LR = 180
const NODE_GAP = 60
const ARROWHEAD_SIZE = 8

/** Internal positioned node used after layout */
interface LayoutNode {
  id: string
  label: string
  type: 'start' | 'end' | 'process' | 'decision' | 'io'
  x: number
  y: number
  width: number
  height: number
  color: string | undefined
  icon: string | undefined
  data: Record<string, string> | undefined
  layer: number
  order: number
  original: FlowNode
}

export class FlowDiagram {
  public readonly el: HTMLDivElement

  private readonly diagramId: string
  private readonly direction: 'TB' | 'LR'
  private readonly zoomable: boolean
  private readonly pannable: boolean
  private readonly onNodeClick: ((node: FlowNode) => void) | undefined
  private readonly onEdgeClick: ((edge: FlowEdge) => void) | undefined
  private svg: SVGSVGElement
  private mainGroup: SVGGElement
  private defsEl: SVGDefsElement
  private nodes: FlowNode[]
  private edges: FlowEdge[]
  private layoutNodes: LayoutNode[] = []
  private selectedNodeId: string | null = null

  // Zoom/pan state
  private viewBox: { x: number; y: number; w: number; h: number }
  private isPanning = false
  private panStart: { x: number; y: number } = { x: 0, y: 0 }
  private panOrigin: { x: number; y: number } = { x: 0, y: 0 }

  // Bound handlers for cleanup
  private readonly boundWheel: (e: WheelEvent) => void
  private readonly boundMouseDown: (e: MouseEvent) => void
  private readonly boundMouseMove: (e: MouseEvent) => void
  private readonly boundMouseUp: () => void

  constructor(config: FlowDiagramConfig) {
    this.diagramId = uniqueId('cmm-flow-diagram')
    this.direction = config.direction ?? 'TB'
    this.zoomable = config.zoomable !== false
    this.pannable = config.pannable !== false
    this.onNodeClick = config.onNodeClick
    this.onEdgeClick = config.onEdgeClick
    this.nodes = [...config.nodes]
    this.edges = [...config.edges]
    this.viewBox = { x: 0, y: 0, w: 800, h: 600 }

    // Build DOM
    this.el = document.createElement('div')
    this.el.id = this.diagramId
    this.el.className = this.buildClassName(config.className)

    this.svg = document.createElementNS(SVG_NS, 'svg')
    this.svg.classList.add('cmm-flow-diagram__svg')

    this.defsEl = document.createElementNS(SVG_NS, 'defs')
    this.svg.appendChild(this.defsEl)

    this.mainGroup = document.createElementNS(SVG_NS, 'g')
    this.svg.appendChild(this.mainGroup)
    this.el.appendChild(this.svg)

    // Create arrowhead marker
    this.createArrowMarker()

    // Mount
    const parent = resolveContainer(config.container)
    parent.appendChild(this.el)

    // Get container dimensions for initial viewbox
    const rect = this.el.getBoundingClientRect()
    if (rect.width > 0 && rect.height > 0) {
      this.viewBox.w = rect.width
      this.viewBox.h = rect.height
    }

    // Bind handlers
    this.boundWheel = (e: WheelEvent): void => {
      this.handleWheel(e)
    }
    this.boundMouseDown = (e: MouseEvent): void => {
      this.handleMouseDown(e)
    }
    this.boundMouseMove = (e: MouseEvent): void => {
      this.handleMouseMove(e)
    }
    this.boundMouseUp = (): void => {
      this.handleMouseUp()
    }

    if (this.zoomable) {
      this.el.addEventListener('wheel', this.boundWheel, { passive: false })
    }
    if (this.pannable) {
      this.el.addEventListener('mousedown', this.boundMouseDown)
      document.addEventListener('mousemove', this.boundMouseMove)
      document.addEventListener('mouseup', this.boundMouseUp)
    }

    this.render()
    this.zoomToFit()
  }

  /** Replace nodes and edges and re-render */
  update(nodes: FlowNode[], edges: FlowEdge[]): void {
    this.nodes = [...nodes]
    this.edges = [...edges]
    this.selectedNodeId = null
    this.render()
    this.zoomToFit()
  }

  /** Auto-fit the diagram content in the viewport */
  zoomToFit(): void {
    if (this.layoutNodes.length === 0) return

    let minX = Infinity
    let minY = Infinity
    let maxX = -Infinity
    let maxY = -Infinity

    for (const n of this.layoutNodes) {
      if (n.x < minX) minX = n.x
      if (n.y < minY) minY = n.y
      if (n.x + n.width > maxX) maxX = n.x + n.width
      if (n.y + n.height > maxY) maxY = n.y + n.height
    }

    const padding = 40
    this.viewBox = {
      x: minX - padding,
      y: minY - padding,
      w: maxX - minX + padding * 2,
      h: maxY - minY + padding * 2,
    }
    this.applyViewBox()
  }

  /** Tear down and remove from DOM */
  destroy(): void {
    if (this.zoomable) {
      this.el.removeEventListener('wheel', this.boundWheel)
    }
    if (this.pannable) {
      this.el.removeEventListener('mousedown', this.boundMouseDown)
      document.removeEventListener('mousemove', this.boundMouseMove)
      document.removeEventListener('mouseup', this.boundMouseUp)
    }
    this.el.remove()
  }

  // ---------------------------------------------------------------------------
  // Layout
  // ---------------------------------------------------------------------------

  private computeLayout(): void {
    // Check if all nodes have explicit positions
    const allPositioned = this.nodes.every(n => n.x !== undefined && n.y !== undefined)

    if (allPositioned) {
      this.layoutNodes = this.nodes.map(n => ({
        id: n.id,
        label: n.label,
        type: n.type ?? 'process',
        x: n.x ?? 0,
        y: n.y ?? 0,
        width: n.width ?? DEFAULT_NODE_WIDTH,
        height: n.height ?? DEFAULT_NODE_HEIGHT,
        color: n.color,
        icon: n.icon,
        data: n.data,
        layer: 0,
        order: 0,
        original: n,
      }))
      return
    }

    // Auto-layout via topological layering
    const adjacency = new Map<string, string[]>()
    const inDegree = new Map<string, number>()

    for (const node of this.nodes) {
      adjacency.set(node.id, [])
      inDegree.set(node.id, 0)
    }

    for (const edge of this.edges) {
      const adj = adjacency.get(edge.source)
      if (adj !== undefined) {
        adj.push(edge.target)
      }
      const deg = inDegree.get(edge.target)
      if (deg !== undefined) {
        inDegree.set(edge.target, deg + 1)
      }
    }

    // Assign layers using longest-path (Kahn's-like)
    const layers = new Map<string, number>()
    const queue: string[] = []

    for (const [id, deg] of inDegree) {
      if (deg === 0) {
        queue.push(id)
        layers.set(id, 0)
      }
    }

    // Fallback: if no roots, pick first node
    if (queue.length === 0 && this.nodes.length > 0) {
      const first = this.nodes[0]
      if (first !== undefined) {
        queue.push(first.id)
        layers.set(first.id, 0)
      }
    }

    while (queue.length > 0) {
      const current = queue.shift()
      if (current === undefined) continue
      const currentLayer = layers.get(current) ?? 0
      const neighbors = adjacency.get(current)
      if (neighbors === undefined) continue

      for (const neighbor of neighbors) {
        const existingLayer = layers.get(neighbor)
        const newLayer = currentLayer + 1
        if (existingLayer === undefined || newLayer > existingLayer) {
          layers.set(neighbor, newLayer)
        }
        const deg = inDegree.get(neighbor)
        if (deg !== undefined) {
          inDegree.set(neighbor, deg - 1)
          const updatedDeg = deg - 1
          if (updatedDeg <= 0) {
            queue.push(neighbor)
          }
        }
      }
    }

    // Assign unvisited nodes to layer 0
    for (const node of this.nodes) {
      if (!layers.has(node.id)) {
        layers.set(node.id, 0)
      }
    }

    // Group by layer
    const layerGroups = new Map<number, FlowNode[]>()
    for (const node of this.nodes) {
      const l = layers.get(node.id) ?? 0
      const group = layerGroups.get(l)
      if (group !== undefined) {
        group.push(node)
      } else {
        layerGroups.set(l, [node])
      }
    }

    // Compute positions
    const isTB = this.direction === 'TB'
    const layerGap = isTB ? LAYER_GAP_TB : LAYER_GAP_LR

    this.layoutNodes = []

    for (const [layerIdx, group] of layerGroups) {
      for (let i = 0; i < group.length; i++) {
        const node = group[i]
        if (node === undefined) continue
        const w = node.width ?? DEFAULT_NODE_WIDTH
        const h = node.height ?? DEFAULT_NODE_HEIGHT

        let x: number
        let y: number
        if (isTB) {
          x = i * (w + NODE_GAP)
          y = layerIdx * (h + layerGap)
        } else {
          x = layerIdx * (w + layerGap)
          y = i * (h + NODE_GAP)
        }

        this.layoutNodes.push({
          id: node.id,
          label: node.label,
          type: node.type ?? 'process',
          x,
          y,
          width: w,
          height: h,
          color: node.color,
          icon: node.icon,
          data: node.data,
          layer: layerIdx,
          order: i,
          original: node,
        })
      }
    }

    // Center layers (align to widest/tallest layer)
    if (isTB) {
      this.centerLayersTB()
    } else {
      this.centerLayersLR()
    }
  }

  private centerLayersTB(): void {
    const layerWidths = new Map<number, { min: number; max: number }>()
    for (const n of this.layoutNodes) {
      const existing = layerWidths.get(n.layer)
      const nodeRight = n.x + n.width
      if (existing !== undefined) {
        if (n.x < existing.min) existing.min = n.x
        if (nodeRight > existing.max) existing.max = nodeRight
      } else {
        layerWidths.set(n.layer, { min: n.x, max: nodeRight })
      }
    }

    let globalMax = 0
    for (const bounds of layerWidths.values()) {
      const w = bounds.max - bounds.min
      if (w > globalMax) globalMax = w
    }

    for (const n of this.layoutNodes) {
      const bounds = layerWidths.get(n.layer)
      if (bounds !== undefined) {
        const layerW = bounds.max - bounds.min
        const offset = (globalMax - layerW) / 2
        n.x += offset - bounds.min
      }
    }
  }

  private centerLayersLR(): void {
    const layerHeights = new Map<number, { min: number; max: number }>()
    for (const n of this.layoutNodes) {
      const existing = layerHeights.get(n.layer)
      const nodeBottom = n.y + n.height
      if (existing !== undefined) {
        if (n.y < existing.min) existing.min = n.y
        if (nodeBottom > existing.max) existing.max = nodeBottom
      } else {
        layerHeights.set(n.layer, { min: n.y, max: nodeBottom })
      }
    }

    let globalMax = 0
    for (const bounds of layerHeights.values()) {
      const h = bounds.max - bounds.min
      if (h > globalMax) globalMax = h
    }

    for (const n of this.layoutNodes) {
      const bounds = layerHeights.get(n.layer)
      if (bounds !== undefined) {
        const layerH = bounds.max - bounds.min
        const offset = (globalMax - layerH) / 2
        n.y += offset - bounds.min
      }
    }
  }

  // ---------------------------------------------------------------------------
  // Rendering
  // ---------------------------------------------------------------------------

  private render(): void {
    // Clear previous content but preserve defs
    while (this.mainGroup.firstChild !== null) {
      this.mainGroup.removeChild(this.mainGroup.firstChild)
    }

    this.computeLayout()

    const nodeMap = new Map<string, LayoutNode>()
    for (const n of this.layoutNodes) {
      nodeMap.set(n.id, n)
    }

    // Render edges first (behind nodes)
    for (const edge of this.edges) {
      const source = nodeMap.get(edge.source)
      const target = nodeMap.get(edge.target)
      if (source === undefined || target === undefined) continue
      this.renderEdge(edge, source, target)
    }

    // Render nodes
    for (const node of this.layoutNodes) {
      this.renderNode(node)
    }

    this.applyViewBox()
  }

  private renderNode(node: LayoutNode): void {
    const g = document.createElementNS(SVG_NS, 'g')
    g.classList.add('cmm-flow-diagram__node')
    if (node.id === this.selectedNodeId) {
      g.classList.add('cmm-flow-diagram__node--selected')
    }
    g.setAttribute('data-node-id', node.id)

    const shape = this.createNodeShape(node)
    g.appendChild(shape)

    // Label
    const text = document.createElementNS(SVG_NS, 'text')
    text.classList.add('cmm-flow-diagram__label')
    text.setAttribute('x', String(node.x + node.width / 2))
    text.setAttribute('y', String(node.y + node.height / 2))
    text.textContent = node.label
    g.appendChild(text)

    // Click handler
    g.addEventListener('click', (e: MouseEvent) => {
      e.stopPropagation()
      this.selectedNodeId = node.id
      this.render()
      this.onNodeClick?.(node.original)
    })

    this.mainGroup.appendChild(g)
  }

  private createNodeShape(node: LayoutNode): SVGElement {
    const { x, y, width: w, height: h } = node

    let shape: SVGElement

    switch (node.type) {
      case 'start':
      case 'end': {
        // Ellipse
        const ellipse = document.createElementNS(SVG_NS, 'ellipse')
        ellipse.setAttribute('cx', String(x + w / 2))
        ellipse.setAttribute('cy', String(y + h / 2))
        ellipse.setAttribute('rx', String(w / 2))
        ellipse.setAttribute('ry', String(h / 2))
        shape = ellipse
        break
      }
      case 'decision': {
        // Diamond (rotated rect via polygon)
        const cx = x + w / 2
        const cy = y + h / 2
        const polygon = document.createElementNS(SVG_NS, 'polygon')
        polygon.setAttribute(
          'points',
          `${String(cx)},${String(y)} ${String(x + w)},${String(cy)} ${String(cx)},${String(y + h)} ${String(x)},${String(cy)}`
        )
        shape = polygon
        break
      }
      case 'io': {
        // Parallelogram
        const skew = w * 0.15
        const polygon = document.createElementNS(SVG_NS, 'polygon')
        polygon.setAttribute(
          'points',
          `${String(x + skew)},${String(y)} ${String(x + w)},${String(y)} ${String(x + w - skew)},${String(y + h)} ${String(x)},${String(y + h)}`
        )
        shape = polygon
        break
      }
      default: {
        // Rounded rectangle for 'process'
        const rect = document.createElementNS(SVG_NS, 'rect')
        rect.setAttribute('x', String(x))
        rect.setAttribute('y', String(y))
        rect.setAttribute('width', String(w))
        rect.setAttribute('height', String(h))
        rect.setAttribute('rx', '8')
        rect.setAttribute('ry', '8')
        shape = rect
        break
      }
    }

    shape.classList.add('cmm-flow-diagram__node-shape')

    if (node.color !== undefined) {
      shape.setAttribute('fill', node.color)
    }

    return shape
  }

  private renderEdge(edge: FlowEdge, source: LayoutNode, target: LayoutNode): void {
    const g = document.createElementNS(SVG_NS, 'g')
    g.classList.add('cmm-flow-diagram__edge')

    const edgeType = edge.type ?? 'solid'
    if (edgeType === 'dashed') {
      g.classList.add('cmm-flow-diagram__edge--dashed')
    } else if (edgeType === 'dotted') {
      g.classList.add('cmm-flow-diagram__edge--dotted')
    }

    if (edge.animated === true) {
      g.classList.add('cmm-flow-diagram__edge--animated')
    }

    g.setAttribute('data-edge-id', edge.id)

    // Compute connection points
    const srcPt = this.getNodePort(source, 'out')
    const tgtPt = this.getNodePort(target, 'in')

    // Path with a smooth curve
    const path = document.createElementNS(SVG_NS, 'path')
    path.classList.add('cmm-flow-diagram__edge-path')

    const isTB = this.direction === 'TB'
    let d: string
    if (isTB) {
      const cpDist = Math.abs(tgtPt.y - srcPt.y) / 2
      d = `M ${String(srcPt.x)} ${String(srcPt.y)} C ${String(srcPt.x)} ${String(srcPt.y + cpDist)}, ${String(tgtPt.x)} ${String(tgtPt.y - cpDist)}, ${String(tgtPt.x)} ${String(tgtPt.y)}`
    } else {
      const cpDist = Math.abs(tgtPt.x - srcPt.x) / 2
      d = `M ${String(srcPt.x)} ${String(srcPt.y)} C ${String(srcPt.x + cpDist)} ${String(srcPt.y)}, ${String(tgtPt.x - cpDist)} ${String(tgtPt.y)}, ${String(tgtPt.x)} ${String(tgtPt.y)}`
    }
    path.setAttribute('d', d)
    path.setAttribute('marker-end', `url(#${this.diagramId}-arrow)`)

    if (edge.color !== undefined) {
      path.setAttribute('stroke', edge.color)
    }

    g.appendChild(path)

    // Edge label
    if (edge.label !== undefined) {
      const midX = (srcPt.x + tgtPt.x) / 2
      const midY = (srcPt.y + tgtPt.y) / 2
      const text = document.createElementNS(SVG_NS, 'text')
      text.classList.add('cmm-flow-diagram__edge-label')
      text.setAttribute('x', String(midX))
      text.setAttribute('y', String(midY - 6))
      text.textContent = edge.label
      g.appendChild(text)
    }

    // Click
    g.addEventListener('click', (e: MouseEvent) => {
      e.stopPropagation()
      this.onEdgeClick?.(edge)
    })

    this.mainGroup.appendChild(g)
  }

  private getNodePort(node: LayoutNode, direction: 'in' | 'out'): { x: number; y: number } {
    const cx = node.x + node.width / 2
    const cy = node.y + node.height / 2

    if (this.direction === 'TB') {
      return direction === 'out' ? { x: cx, y: node.y + node.height } : { x: cx, y: node.y }
    }
    // LR
    return direction === 'out' ? { x: node.x + node.width, y: cy } : { x: node.x, y: cy }
  }

  private createArrowMarker(): void {
    const marker = document.createElementNS(SVG_NS, 'marker')
    marker.setAttribute('id', `${this.diagramId}-arrow`)
    marker.setAttribute('markerWidth', String(ARROWHEAD_SIZE))
    marker.setAttribute('markerHeight', String(ARROWHEAD_SIZE))
    marker.setAttribute('refX', String(ARROWHEAD_SIZE))
    marker.setAttribute('refY', String(ARROWHEAD_SIZE / 2))
    marker.setAttribute('orient', 'auto-start-reverse')
    marker.setAttribute('markerUnits', 'userSpaceOnUse')

    const polyline = document.createElementNS(SVG_NS, 'polygon')
    polyline.classList.add('cmm-flow-diagram__arrowhead')
    polyline.setAttribute(
      'points',
      `0 0, ${String(ARROWHEAD_SIZE)} ${String(ARROWHEAD_SIZE / 2)}, 0 ${String(ARROWHEAD_SIZE)}`
    )
    marker.appendChild(polyline)
    this.defsEl.appendChild(marker)
  }

  // ---------------------------------------------------------------------------
  // Zoom / Pan
  // ---------------------------------------------------------------------------

  private applyViewBox(): void {
    this.svg.setAttribute(
      'viewBox',
      `${String(this.viewBox.x)} ${String(this.viewBox.y)} ${String(this.viewBox.w)} ${String(this.viewBox.h)}`
    )
  }

  private handleWheel(e: WheelEvent): void {
    e.preventDefault()
    const factor = e.deltaY > 0 ? 1.1 : 0.9

    const rect = this.el.getBoundingClientRect()
    const mx = ((e.clientX - rect.left) / rect.width) * this.viewBox.w + this.viewBox.x
    const my = ((e.clientY - rect.top) / rect.height) * this.viewBox.h + this.viewBox.y

    const newW = this.viewBox.w * factor
    const newH = this.viewBox.h * factor

    this.viewBox.x = mx - (mx - this.viewBox.x) * (newW / this.viewBox.w)
    this.viewBox.y = my - (my - this.viewBox.y) * (newH / this.viewBox.h)
    this.viewBox.w = newW
    this.viewBox.h = newH

    this.applyViewBox()
  }

  private handleMouseDown(e: MouseEvent): void {
    // Only pan on background clicks (button 0)
    if (e.button !== 0) return
    const target = e.target as Element | null
    if (
      target !== null &&
      target.closest('.cmm-flow-diagram__node, .cmm-flow-diagram__edge') !== null
    ) {
      return
    }

    this.isPanning = true
    this.panStart = { x: e.clientX, y: e.clientY }
    this.panOrigin = { x: this.viewBox.x, y: this.viewBox.y }
  }

  private handleMouseMove(e: MouseEvent): void {
    if (!this.isPanning) return

    const rect = this.el.getBoundingClientRect()
    const scaleX = this.viewBox.w / rect.width
    const scaleY = this.viewBox.h / rect.height

    this.viewBox.x = this.panOrigin.x - (e.clientX - this.panStart.x) * scaleX
    this.viewBox.y = this.panOrigin.y - (e.clientY - this.panStart.y) * scaleY

    this.applyViewBox()
  }

  private handleMouseUp(): void {
    this.isPanning = false
  }

  // ---------------------------------------------------------------------------
  // Helpers
  // ---------------------------------------------------------------------------

  private buildClassName(extra: string | undefined): string {
    const classes = ['cmm-flow-diagram']
    if (extra !== undefined) {
      classes.push(extra)
    }
    return classes.join(' ')
  }
}
