import React, { useRef, useEffect, useState, useCallback } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import * as d3 from 'd3'
import { constellationNodes, constellationLinks, roleSkillMappings } from '@/data/constellation'
import { timelineCareerEntities } from '@/data/timeline'
import type { ConstellationNode } from '@/types/pmr'

interface CareerConstellationProps {
  onRoleClick: (id: string) => void
  onSkillClick: (id: string) => void
  onNodeHover?: (id: string | null) => void
  highlightedNodeId?: string | null
  containerHeight?: number | null
}

const MIN_HEIGHT = 400
const MOBILE_FALLBACK_HEIGHT = 520

// Desktop defaults — mobile overrides computed in the D3 effect
const ROLE_WIDTH = 104
const ROLE_HEIGHT = 32
const ROLE_RX = 16
const SKILL_RADIUS_DEFAULT = 7
const SKILL_RADIUS_ACTIVE = 11

const MOBILE_ROLE_WIDTH = 80
const MOBILE_SKILL_RADIUS_DEFAULT = 6
const MOBILE_SKILL_RADIUS_ACTIVE = 9

const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
const supportsCoarsePointer = window.matchMedia('(pointer: coarse)').matches

const domainColorMap: Record<string, string> = {
  clinical: '#059669',
  technical: '#0D6E6E',
  leadership: '#D97706',
}
const roleNodes = constellationNodes.filter(n => n.type === 'role')
const nodeById = new Map(constellationNodes.map(node => [node.id, node]))
const careerEntityById = new Map(timelineCareerEntities.map(entity => [entity.id, entity]))
const srDescription = buildScreenReaderDescription()

function getHeight(width: number, containerHeight?: number | null): number {
  // Mobile/tablet: use fallback since columns stack vertically
  if (width < 1024) return MOBILE_FALLBACK_HEIGHT
  // Desktop: use measured container height if available, with minimum
  if (containerHeight && containerHeight > 0) return Math.max(MIN_HEIGHT, containerHeight)
  return MIN_HEIGHT
}

interface SimNode extends ConstellationNode {
  x: number
  y: number
  vx: number
  vy: number
  fx?: number | null
  fy?: number | null
  homeX: number
  homeY: number
}

interface SimLink {
  source: SimNode | string
  target: SimNode | string
  strength: number
}

function hashString(input: string): number {
  let hash = 0
  for (let i = 0; i < input.length; i++) {
    hash = (hash << 5) - hash + input.charCodeAt(i)
    hash |= 0
  }
  return Math.abs(hash)
}

function buildScreenReaderDescription(): string {
  const roleNodes = constellationNodes.filter(n => n.type === 'role')
  const skillNodes = constellationNodes.filter(n => n.type === 'skill')

  const roleDescriptions = roleNodes.map(role => {
    const mapping = roleSkillMappings.find(m => m.roleId === role.id)
    const skillNames = mapping
      ? mapping.skillIds
          .map(sid => skillNodes.find(s => s.id === sid)?.label)
          .filter(Boolean)
          .join(', ')
      : ''
    const yearRange = role.endYear
      ? `${role.startYear}-${role.endYear}`
      : `${role.startYear}-present`
    return `${role.label} at ${role.organization} (${yearRange}): ${skillNames}`
  })

  return `Career constellation graph showing ${roleNodes.length} roles and ${skillNodes.length} skills in reverse-chronological order along a vertical timeline, with the most recent role at the top. ` +
    roleDescriptions.join('. ') + '.'
}

const CareerConstellation: React.FC<CareerConstellationProps> = ({
  onRoleClick,
  onSkillClick,
  onNodeHover,
  highlightedNodeId,
  containerHeight,
}) => {
  const svgRef = useRef<SVGSVGElement>(null)
  const containerRef = useRef<HTMLDivElement>(null)
  const simulationRef = useRef<d3.Simulation<SimNode, SimLink> | null>(null)
  const highlightGraphRef = useRef<((activeNodeId: string | null) => void) | null>(null)
  const callbacksRef = useRef({ onRoleClick, onSkillClick, onNodeHover })
  const highlightedNodeIdRef = useRef<string | null>(highlightedNodeId ?? null)
  const pinnedNodeIdRef = useRef<string | null>(null)
  const [dimensions, setDimensions] = useState({ width: 800, height: MIN_HEIGHT, scaleFactor: 1 })
  const [focusedNodeId, setFocusedNodeId] = useState<string | null>(null)
  const [pinnedNodeId, setPinnedNodeId] = useState<string | null>(null)
  const [accordionShowMore, setAccordionShowMore] = useState(false)
  const [nodeButtonPositions, setNodeButtonPositions] = useState<Record<string, { x: number; y: number }>>({})

  callbacksRef.current = { onRoleClick, onSkillClick, onNodeHover }

  const resolveGraphFallback = useCallback(
    () => highlightedNodeIdRef.current ?? pinnedNodeIdRef.current,
    [],
  )

  const resolveRoleFallback = useCallback(() => {
    const highlightedId = highlightedNodeIdRef.current
    if (highlightedId && nodeById.get(highlightedId)?.type === 'role') {
      return highlightedId
    }

    const pinnedId = pinnedNodeIdRef.current
    if (pinnedId && nodeById.get(pinnedId)?.type === 'role') {
      return pinnedId
    }

    return null
  }, [])

  const handleNodeKeyDown = useCallback((e: React.KeyboardEvent, nodeId: string, nodeType: 'role' | 'skill') => {
    if (e.key === 'Enter' || e.key === ' ') {
      e.preventDefault()
      setPinnedNodeId(nodeId)
      pinnedNodeIdRef.current = nodeId
      highlightGraphRef.current?.(nodeId)
      if (nodeType === 'role') {
        onNodeHover?.(nodeId)
      } else {
        onNodeHover?.(resolveRoleFallback())
      }
      if (nodeType === 'role') {
        onRoleClick(nodeId)
      } else {
        onSkillClick(nodeId)
      }
    }
  }, [onRoleClick, onSkillClick, onNodeHover, resolveRoleFallback])

  useEffect(() => {
    const container = containerRef.current
    if (!container) return

    const updateDimensions = () => {
      const width = container.clientWidth
      // Use viewport width for breakpoint check since container may overflow on mobile
      const viewportWidth = window.innerWidth
      const height = getHeight(viewportWidth, containerHeight)
      // Viewport-proportional scaling: 1.0x at 1440px, up to 1.6x at 2560px+
      // Only applies on desktop (>=1024px); mobile/tablet stays at 1.0
      const scaleFactor = viewportWidth >= 1024
        ? Math.max(1, Math.min(1.6, viewportWidth / 1440))
        : 1
      setDimensions({ width, height, scaleFactor })
    }

    updateDimensions()

    const observer = new ResizeObserver(updateDimensions)
    observer.observe(container)

    return () => observer.disconnect()
  }, [containerHeight])

  useEffect(() => {
    highlightedNodeIdRef.current = highlightedNodeId ?? null
  }, [highlightedNodeId])

  useEffect(() => {
    pinnedNodeIdRef.current = pinnedNodeId
  }, [pinnedNodeId])

  useEffect(() => {
    const svg = d3.select(svgRef.current)
    if (!svgRef.current) return

    const { width, height, scaleFactor } = dimensions
    // Use viewport width for responsive breakpoint — container.clientWidth overflows on mobile
    const isMobile = window.innerWidth < 640
    const sf = isMobile ? 1 : scaleFactor

    if (simulationRef.current) {
      simulationRef.current.stop()
    }

    svg.selectAll('*').remove()

    const years = roleNodes.map(n => n.startYear ?? 2016)
    const minYear = Math.min(...years)
    const maxYear = Math.max(...years)

    // Responsive layout parameters — desktop values scale proportionally with viewport
    const rw = isMobile ? MOBILE_ROLE_WIDTH : Math.round(ROLE_WIDTH * sf)
    const rh = isMobile ? ROLE_HEIGHT : Math.round(ROLE_HEIGHT * sf)
    const rrx = isMobile ? ROLE_RX : Math.round(ROLE_RX * sf)
    const srDefault = isMobile ? MOBILE_SKILL_RADIUS_DEFAULT : Math.round(SKILL_RADIUS_DEFAULT * sf)
    const srActive = isMobile ? MOBILE_SKILL_RADIUS_ACTIVE : Math.round(SKILL_RADIUS_ACTIVE * sf)

    const topPadding = isMobile ? 36 : Math.round(46 * sf)
    const bottomPadding = isMobile ? 40 : Math.round(46 * sf)
    const sidePadding = isMobile ? 20 : Math.round(36 * sf)
    const timelineX = isMobile
      ? Math.max(60, width * 0.16)
      : Math.max(Math.round(100 * sf), Math.min(Math.round(160 * sf), width * 0.18))

    const yScale = d3.scaleLinear()
      .domain([maxYear, minYear])
      .range([topPadding, height - bottomPadding])

    svg.append('rect')
      .attr('class', 'bg-rect')
      .attr('width', width)
      .attr('height', height)
      .attr('fill', 'var(--surface)')
      .attr('rx', 6)

    // SVG filter defs for role node shadows
    const defs = svg.append('defs')

    const shadowSm = defs.append('filter')
      .attr('id', 'shadow-sm-filter')
      .attr('x', '-20%').attr('y', '-20%')
      .attr('width', '140%').attr('height', '140%')
    shadowSm.append('feDropShadow')
      .attr('dx', 0).attr('dy', 1)
      .attr('stdDeviation', 1.5)
      .attr('flood-color', 'rgba(26,43,42,0.08)')

    const shadowMd = defs.append('filter')
      .attr('id', 'shadow-md-filter')
      .attr('x', '-30%').attr('y', '-30%')
      .attr('width', '160%').attr('height', '160%')
    shadowMd.append('feDropShadow')
      .attr('dx', 0).attr('dy', 2)
      .attr('stdDeviation', 3)
      .attr('flood-color', 'rgba(26,43,42,0.12)')

    // Timeline guides and subtle era lanes
    const timelineGroup = svg.append('g').attr('class', 'timeline-guides')

    const tickYears = d3.range(minYear, maxYear + 1)
    timelineGroup.selectAll('line.year-guide')
      .data(tickYears)
      .join('line')
      .attr('class', 'year-guide')
      .attr('x1', sidePadding)
      .attr('x2', width - sidePadding)
      .attr('y1', d => yScale(d))
      .attr('y2', d => yScale(d))
      .attr('stroke', 'var(--border-light)')
      .attr('stroke-opacity', 0.25)
      .attr('stroke-width', 1)
      .attr('stroke-dasharray', '3 4')

    timelineGroup.append('line')
      .attr('x1', timelineX)
      .attr('x2', timelineX)
      .attr('y1', topPadding - 12)
      .attr('y2', height - bottomPadding + 12)
      .attr('stroke', 'var(--border)')
      .attr('stroke-width', 1)

    timelineGroup.selectAll('line.year-tick')
      .data(tickYears)
      .join('line')
      .attr('class', 'year-tick')
      .attr('x1', timelineX)
      .attr('x2', d => timelineX + (roleNodes.some(r => r.startYear === d) ? 8 : 6))
      .attr('y1', d => yScale(d))
      .attr('y2', d => yScale(d))
      .attr('stroke', 'var(--border)')
      .attr('stroke-width', 1)
      .attr('stroke-opacity', d => roleNodes.some(r => r.startYear === d) ? 0.8 : 0.4)

    timelineGroup.selectAll('text.year-label')
      .data(tickYears)
      .join('text')
      .attr('class', 'year-label')
      .attr('x', timelineX - (isMobile ? 8 : Math.round(12 * sf)))
      .attr('y', d => yScale(d) + Math.round(4 * sf))
      .attr('text-anchor', 'end')
      .attr('font-size', isMobile ? '9' : `${Math.round(11 * sf)}`)
      .attr('font-family', 'var(--font-geist-mono)')
      .attr('fill', 'var(--text-tertiary)')
      .text(d => d)

    // Prepare data with deterministic initial positions
    const links: SimLink[] = constellationLinks.map(l => ({
      source: l.source,
      target: l.target,
      strength: l.strength,
    }))

    const roleOrder = [...roleNodes].sort((a, b) => (a.startYear ?? 0) - (b.startYear ?? 0))
    const roleInitialMap = new Map<string, { x: number; y: number }>()
    // Consistent horizontal offset for all role nodes — anchored right of timeline
    const roleGap = isMobile ? 40 : Math.round(56 * sf)
    const roleX = Math.min(width - sidePadding - rw / 2, timelineX + roleGap + rw / 2)

    roleOrder.forEach((role) => {
      roleInitialMap.set(role.id, {
        x: roleX,
        y: yScale(role.startYear ?? minYear),
      })
    })

    const nodes: SimNode[] = constellationNodes.map(n => {
      if (n.type === 'role') {
        const pos = roleInitialMap.get(n.id)!
        return {
          ...n,
          x: pos.x,
          y: pos.y,
          vx: 0,
          vy: 0,
          homeX: pos.x,
          homeY: pos.y,
        }
      }

      const roleIds = constellationLinks
        .filter(l => l.target === n.id)
        .map(l => l.source)

      const linkedRolePositions = roleIds
        .map(roleId => roleInitialMap.get(roleId))
        .filter(Boolean) as Array<{ x: number; y: number }>

      // Skill centroid: offset right of roles into the available distribution space
      const skillGap = isMobile ? 20 : Math.round(28 * sf)
      const skillSpaceStart = roleX + rw / 2 + skillGap
      const skillSpaceMid = (skillSpaceStart + width - sidePadding) / 2
      const centroid = linkedRolePositions.length > 0
        ? {
          x: Math.max(skillSpaceStart, linkedRolePositions.reduce((sum, p) => sum + p.x, 0) / linkedRolePositions.length + (isMobile ? 30 : Math.round(40 * sf))),
          y: linkedRolePositions.reduce((sum, p) => sum + p.y, 0) / linkedRolePositions.length,
        }
        : { x: skillSpaceMid, y: height * 0.5 }

      const hash = hashString(n.id)
      const domainBaseAngle = n.domain === 'clinical'
        ? Math.PI * 0.5
        : n.domain === 'leadership'
          ? Math.PI * 1.35
          : Math.PI * 0.05
      const angle = domainBaseAngle + ((hash % 360) * Math.PI / 180) * 0.18
      const radius = (isMobile ? 25 : Math.round(35 * sf)) + (hash % (isMobile ? 25 : Math.round(35 * sf)))

      const seededX = centroid.x + Math.cos(angle) * radius
      const seededY = centroid.y + Math.sin(angle) * radius

      return {
        ...n,
        x: seededX,
        y: seededY,
        vx: 0,
        vy: 0,
        homeX: seededX,
        homeY: seededY,
      }
    })

    const linkGroup = svg.append('g').attr('class', 'links')
    const connectorGroup = svg.append('g').attr('class', 'connectors')
    const nodeGroup = svg.append('g').attr('class', 'nodes')

    const linkSelection = linkGroup.selectAll('path')
      .data(links)
      .join('path')
      .attr('fill', 'none')
      .attr('stroke', 'var(--border-light)')
      .attr('stroke-width', 1)
      .attr('stroke-opacity', 0.15)
      .style('transition', prefersReducedMotion
        ? 'none'
        : 'stroke 150ms ease, stroke-opacity 150ms ease, stroke-width 150ms ease'
      )

    const nodeSelection = nodeGroup.selectAll<SVGGElement, SimNode>('g')
      .data(nodes)
      .join('g')
      .attr('class', d => `node node-${d.type}`)
      .style('cursor', 'pointer')
      .attr('data-node-id', d => d.id)

    nodeSelection.filter(d => d.type === 'role')
      .append('rect')
      .attr('class', 'focus-ring')
      .attr('x', -rw / 2 - 3)
      .attr('y', -rh / 2 - 3)
      .attr('width', rw + 6)
      .attr('height', rh + 6)
      .attr('rx', rrx + 2)
      .attr('fill', 'none')
      .attr('stroke', 'transparent')
      .attr('stroke-width', 2)

    nodeSelection.filter(d => d.type === 'role')
      .append('rect')
      .attr('class', 'node-circle')
      .attr('x', -rw / 2)
      .attr('y', -rh / 2)
      .attr('width', rw)
      .attr('height', rh)
      .attr('rx', rrx)
      .attr('fill', d => d.orgColor ?? 'var(--accent)')
      .attr('fill-opacity', 0.12)
      .attr('stroke', d => d.orgColor ?? 'var(--accent)')
      .attr('stroke-opacity', 0.4)
      .attr('stroke-width', 1)

    const mobileLabelMaxLen = 10
    nodeSelection.filter(d => d.type === 'role')
      .append('text')
      .attr('class', 'node-label')
      .attr('text-anchor', 'middle')
      .attr('dominant-baseline', 'central')
      .attr('fill', d => d.orgColor ?? 'var(--accent)')
      .attr('font-size', isMobile ? '10' : `${Math.round(12 * sf)}`)
      .attr('font-weight', '600')
      .attr('font-family', 'var(--font-ui)')
      .attr('pointer-events', 'none')
      .text(d => {
        const label = d.shortLabel ?? d.label.slice(0, 12)
        return isMobile && label.length > mobileLabelMaxLen ? `${label.slice(0, mobileLabelMaxLen - 1)}…` : label
      })

    nodeSelection.filter(d => d.type === 'skill')
      .append('circle')
      .attr('class', 'focus-ring')
      .attr('r', srActive + 3)
      .attr('fill', 'none')
      .attr('stroke', 'transparent')
      .attr('stroke-width', 2)

    nodeSelection.filter(d => d.type === 'skill')
      .append('circle')
      .attr('class', 'node-circle')
      .attr('r', srDefault)
      .attr('fill', d => domainColorMap[d.domain ?? 'technical'] ?? '#0D6E6E')
      .attr('stroke', 'none')
      .attr('fill-opacity', 0.35)

    nodeSelection.filter(d => d.type === 'skill')
      .append('text')
      .attr('class', 'node-label')
      .attr('text-anchor', 'middle')
      .attr('dy', srActive + Math.round(14 * sf))
      .attr('fill', 'var(--text-secondary)')
      .attr('font-size', isMobile ? '9' : `${Math.round(11 * sf)}`)
      .attr('font-family', 'var(--font-geist-mono)')
      .attr('pointer-events', 'none')
      .attr('opacity', 0.5)
      .text(d => {
        const label = d.shortLabel ?? d.label
        const maxLen = isMobile ? 12 : width < 500 ? 12 : 16
        return label.length > maxLen ? `${label.slice(0, maxLen - 1)}…` : label
      })

    const roleConnectors = connectorGroup.selectAll('line.role-connector')
      .data(nodes.filter(n => n.type === 'role'))
      .join('line')
      .attr('class', 'role-connector')
      .attr('stroke', 'var(--border)')
      .attr('stroke-width', 1)
      .attr('stroke-opacity', 0.3)

    const connectedMap = new Map<string, Set<string>>()
    constellationLinks.forEach(l => {
      if (!connectedMap.has(l.source)) connectedMap.set(l.source, new Set())
      if (!connectedMap.has(l.target)) connectedMap.set(l.target, new Set())
      connectedMap.get(l.source)!.add(l.target)
      connectedMap.get(l.target)!.add(l.source)
    })
    const applyGraphHighlight = (activeNodeId: string | null) => {
      const dur = prefersReducedMotion ? 0 : 180

      if (!activeNodeId) {
        nodeSelection.style('opacity', '1')

        nodeSelection.filter(d => d.type === 'role')
          .attr('filter', null)
          .select('.node-circle')
          .attr('stroke-opacity', 0.4)
          .attr('stroke-width', 1)

        const skillNodes = nodeSelection.filter(d => d.type === 'skill')
        if (dur > 0) {
          skillNodes.select('.node-circle')
            .transition().duration(dur)
            .attr('r', srDefault)
            .attr('fill-opacity', 0.35)
          skillNodes.select('.node-label')
            .transition().duration(dur)
            .attr('opacity', 0.5)
        } else {
          skillNodes.select('.node-circle')
            .attr('r', srDefault)
            .attr('fill-opacity', 0.35)
          skillNodes.select('.node-label')
            .attr('opacity', 0.5)
        }

        linkSelection
          .attr('stroke', 'var(--border-light)')
          .attr('stroke-width', 1)
          .attr('stroke-opacity', 0.15)

        return
      }

      const connected = connectedMap.get(activeNodeId) ?? new Set()
      const isInGroup = (id: string) => id === activeNodeId || connected.has(id)

      nodeSelection.style('opacity', d => isInGroup(d.id) ? '1' : '0.15')

      nodeSelection.filter(d => d.type === 'role')
        .attr('filter', d => {
          if (d.id === activeNodeId) return 'url(#shadow-md-filter)'
          if (connected.has(d.id)) return 'url(#shadow-sm-filter)'
          return null
        })
        .select('.node-circle')
        .attr('stroke-opacity', d => {
          if (d.id === activeNodeId) return 1
          if (connected.has(d.id)) return 0.7
          return 0.4
        })
        .attr('stroke-width', d => d.id === activeNodeId ? 1.5 : 1)

      const skillNodes = nodeSelection.filter(d => d.type === 'skill')
      if (dur > 0) {
        skillNodes.select('.node-circle')
          .transition().duration(dur)
          .attr('r', d => isInGroup(d.id) ? srActive : srDefault)
          .attr('fill-opacity', d => isInGroup(d.id) ? 0.9 : 0.35)
        skillNodes.select('.node-label')
          .transition().duration(dur)
          .attr('opacity', d => isInGroup(d.id) ? 1 : 0.5)
      } else {
        skillNodes.select('.node-circle')
          .attr('r', d => isInGroup(d.id) ? srActive : srDefault)
          .attr('fill-opacity', d => isInGroup(d.id) ? 0.9 : 0.35)
        skillNodes.select('.node-label')
          .attr('opacity', d => isInGroup(d.id) ? 1 : 0.5)
      }

      linkSelection
        .attr('stroke', l => {
          const src = typeof l.source === 'string' ? l.source : (l.source as SimNode).id
          const tgt = typeof l.target === 'string' ? l.target : (l.target as SimNode).id
          if (src === activeNodeId || tgt === activeNodeId) {
            const skillId = src === activeNodeId ? tgt : src
            const skillNode = nodes.find(n => n.id === skillId)
            return domainColorMap[skillNode?.domain ?? 'technical'] ?? '#0D6E6E'
          }
          return 'var(--border-light)'
        })
        .attr('stroke-opacity', l => {
          const src = typeof l.source === 'string' ? l.source : (l.source as SimNode).id
          const tgt = typeof l.target === 'string' ? l.target : (l.target as SimNode).id
          if (src === activeNodeId || tgt === activeNodeId) {
            return Math.max(0.35, Math.min(0.65, l.strength * 0.55 + 0.2))
          }
          return 0.15
        })
        .attr('stroke-width', l => {
          const src = typeof l.source === 'string' ? l.source : (l.source as SimNode).id
          const tgt = typeof l.target === 'string' ? l.target : (l.target as SimNode).id
          if (src === activeNodeId || tgt === activeNodeId) return 1.5
          return 1
        })
    }

    highlightGraphRef.current = applyGraphHighlight

    // Touch: tap on background to clear pinned highlight and close accordion
    svg.select('.bg-rect').on('click', () => {
      if (supportsCoarsePointer) {
        setPinnedNodeId(null)
        pinnedNodeIdRef.current = null
        applyGraphHighlight(null)
        callbacksRef.current.onNodeHover?.(null)
      }
    })

    nodeSelection.on('mouseenter', function(_event, d) {
      if (supportsCoarsePointer) return
      applyGraphHighlight(d.id)
      if (d.type === 'role') {
        callbacksRef.current.onNodeHover?.(d.id)
      }
    })

    nodeSelection.on('mouseleave', function() {
      if (supportsCoarsePointer) return
      applyGraphHighlight(resolveGraphFallback())
      callbacksRef.current.onNodeHover?.(resolveRoleFallback())
    })

    nodeSelection.on('click', function(_event, d) {
      if (supportsCoarsePointer) {
        // Touch: tap-to-pin toggle
        if (pinnedNodeIdRef.current === d.id) {
          setPinnedNodeId(null)
          pinnedNodeIdRef.current = null
          applyGraphHighlight(null)
          callbacksRef.current.onNodeHover?.(null)
        } else {
          setPinnedNodeId(d.id)
          pinnedNodeIdRef.current = d.id
          applyGraphHighlight(d.id)
          callbacksRef.current.onNodeHover?.(d.type === 'role' ? d.id : resolveRoleFallback())
        }
      }

      // Fire detail callbacks for both desktop and touch
      if (d.type === 'role') {
        callbacksRef.current.onRoleClick(d.id)
      } else {
        callbacksRef.current.onSkillClick(d.id)
      }
    })

    const simulation = d3.forceSimulation<SimNode>(nodes)
      .alpha(0.65)
      .alphaDecay(prefersReducedMotion ? 0.28 : 0.08)
      .force('charge', d3.forceManyBody<SimNode>().strength(d =>
        d.type === 'role' ? (isMobile ? -100 : Math.round(-120 * sf)) : (isMobile ? -45 : Math.round(-55 * sf))
      ))
      .force('link', d3.forceLink<SimNode, SimLink>(links)
        .id(d => d.id)
        .distance(isMobile ? 56 : Math.round(72 * sf))
        .strength(d => (d as SimLink).strength * 0.5))
      .force('x', d3.forceX<SimNode>(d => d.homeX).strength(d => d.type === 'role' ? 1.0 : 0.25))
      .force('y', d3.forceY<SimNode>(d => {
        if (d.type === 'role') {
          return yScale(d.startYear ?? minYear)
        }
        return d.homeY
      }).strength(d => d.type === 'role' ? 0.98 : 0.18))
      .force('collide', d3.forceCollide<SimNode>(d =>
        d.type === 'role' ? Math.max(rw, rh) / 2 + (isMobile ? 8 : Math.round(10 * sf)) : srActive + (isMobile ? 14 : Math.round(16 * sf))
      ).iterations(3))

    simulationRef.current = simulation

    // Padding for skill label text below the node (radius + gap + line height)
    const skillBottomPadding = srActive + Math.round(14 * sf) + Math.round(12 * sf)
    const rightMargin = isMobile ? 16 : Math.round(32 * sf)

    const renderTick = () => {
      nodes.forEach(d => {
        if (d.type === 'role') {
          d.x = Math.max(rw / 2 + 6, Math.min(width - rw / 2 - 6, d.x))
          d.y = Math.max(rh / 2 + topPadding, Math.min(height - rh / 2 - bottomPadding, d.y))
        } else {
          d.x = Math.max(srActive + 6, Math.min(width - srActive - rightMargin, d.x))
          d.y = Math.max(srActive + topPadding, Math.min(height - skillBottomPadding, d.y))
        }
      })

      linkSelection
        .attr('d', d => {
          const sx = (d.source as SimNode).x
          const sy = (d.source as SimNode).y
          const tx = (d.target as SimNode).x
          const ty = (d.target as SimNode).y
          const cx = (sx + tx) / 2
          return `M${sx},${sy} Q${cx},${sy} ${tx},${ty}`
        })

      nodeSelection.attr('transform', d => `translate(${d.x},${d.y})`)

      roleConnectors
        .attr('x1', timelineX)
        .attr('y1', d => d.y)
        .attr('x2', d => d.x - rw / 2)
        .attr('y2', d => d.y)

      const nextNodePositions: Record<string, { x: number; y: number }> = {}
      nodes.forEach(node => {
        nextNodePositions[node.id] = {
          x: Math.round(node.x),
          y: Math.round(node.y),
        }
      })

      setNodeButtonPositions(prev => {
        const prevKeys = Object.keys(prev)
        const nextKeys = Object.keys(nextNodePositions)
        if (prevKeys.length !== nextKeys.length) return nextNodePositions

        for (const key of nextKeys) {
          const prevPos = prev[key]
          const nextPos = nextNodePositions[key]
          if (!prevPos || prevPos.x !== nextPos.x || prevPos.y !== nextPos.y) {
            return nextNodePositions
          }
        }

        return prev
      })

      applyGraphHighlight(resolveGraphFallback())
    }

    if (prefersReducedMotion) {
      simulation.stop()
      for (let i = 0; i < 150; i++) {
        simulation.tick()
      }
      renderTick()
    } else {
      simulation.on('tick', renderTick)
    }

    return () => {
      simulation.stop()
    }
  }, [dimensions, resolveGraphFallback, resolveRoleFallback])

  useEffect(() => {
    if (!svgRef.current) return
    const svg = d3.select(svgRef.current)

    svg.selectAll('.focus-ring')
      .attr('stroke', 'transparent')

    if (focusedNodeId) {
      svg.selectAll<SVGGElement, SimNode>('g.node')
        .filter(d => d.id === focusedNodeId)
        .select('.focus-ring')
        .attr('stroke', 'var(--accent)')
        .attr('stroke-width', 2)
    }
  }, [focusedNodeId])

  useEffect(() => {
    if (!highlightGraphRef.current) return
    highlightGraphRef.current(highlightedNodeId ?? pinnedNodeId)
  }, [highlightedNodeId, pinnedNodeId])

  // Reset "show more" when switching between pinned roles
  useEffect(() => {
    setAccordionShowMore(false)
  }, [pinnedNodeId])

  // Find canonical career entity for pinned role (accordion on mobile)
  const pinnedRoleNode = pinnedNodeId ? constellationNodes.find(n => n.id === pinnedNodeId && n.type === 'role') : null
  const pinnedCareerEntity = pinnedRoleNode ? careerEntityById.get(pinnedRoleNode.id) : null
  const showAccordion = supportsCoarsePointer && pinnedCareerEntity !== null && pinnedCareerEntity !== undefined

  return (
    <div
      ref={containerRef}
      style={{
        width: '100%',
        borderRadius: 'var(--radius-sm)',
        border: '1px solid var(--border-light)',
        overflow: 'hidden',
        position: 'relative',
      }}
    >
      <svg
        ref={svgRef}
        width={dimensions.width}
        height={dimensions.height}
        viewBox={`0 0 ${dimensions.width} ${dimensions.height}`}
        role="img"
        aria-label="Clinical pathway constellation showing career roles and skills in reverse-chronological order along a vertical timeline"
        style={{ display: 'block' }}
      />

      <div
        style={{
          display: 'flex',
          flexWrap: 'wrap',
          alignItems: 'center',
          gap: '12px',
          padding: '6px 12px',
          fontFamily: 'var(--font-geist-mono)',
          fontSize: '10px',
          color: 'var(--text-tertiary)',
          lineHeight: '24px',
        }}
      >
        {[
          { label: 'Technical', color: 'var(--accent)' },
          { label: 'Clinical', color: 'var(--success)' },
          { label: 'Leadership', color: 'var(--amber)' },
        ].map((item, i) => (
          <React.Fragment key={item.label}>
            {i > 0 && (
              <span style={{ color: 'var(--border)', userSelect: 'none' }} aria-hidden="true">·</span>
            )}
            <span style={{ display: 'inline-flex', alignItems: 'center', gap: '5px' }}>
              <span
                style={{
                  display: 'inline-block',
                  width: '6px',
                  height: '6px',
                  borderRadius: '50%',
                  backgroundColor: item.color,
                  flexShrink: 0,
                }}
              />
              {item.label}
            </span>
          </React.Fragment>
        ))}
        <span style={{ color: 'var(--border)', userSelect: 'none' }} aria-hidden="true">·</span>
        <span style={{ opacity: 0.7 }}>{supportsCoarsePointer ? 'Tap to explore connections' : 'Hover to explore connections'}</span>
      </div>

      {/* Mobile accordion: role details on tap */}
      <AnimatePresence>
        {showAccordion && pinnedCareerEntity && (
          <motion.div
            key={pinnedCareerEntity.id}
            initial={{ height: 0 }}
            animate={{ height: 'auto' }}
            exit={{ height: 0 }}
            transition={prefersReducedMotion ? { duration: 0 } : { duration: 0.2, ease: 'easeOut' }}
            style={{ overflow: 'hidden' }}
          >
            <div
              style={{
                padding: '12px 16px',
                borderTop: `1px solid ${pinnedCareerEntity.orgColor ?? 'var(--border-light)'}`,
                fontFamily: 'var(--font-ui)',
              }}
            >
              <div style={{ marginBottom: '8px' }}>
                <div
                  style={{
                    display: 'flex',
                    alignItems: 'center',
                    gap: '8px',
                    marginBottom: '2px',
                  }}
                >
                  <span
                    style={{
                      display: 'inline-block',
                      width: '6px',
                      height: '6px',
                      borderRadius: '50%',
                      backgroundColor: pinnedCareerEntity.orgColor ?? 'var(--accent)',
                      flexShrink: 0,
                    }}
                  />
                  <span
                    style={{
                      fontSize: '13px',
                      fontWeight: 600,
                      color: 'var(--text-primary)',
                    }}
                  >
                    {pinnedCareerEntity.title}
                  </span>
                </div>
                <div
                  style={{
                    fontSize: '11px',
                    color: 'var(--text-secondary)',
                    fontFamily: 'var(--font-geist-mono)',
                    paddingLeft: '14px',
                  }}
                >
                  {pinnedCareerEntity.organization} · {pinnedCareerEntity.dateRange.display}
                </div>
              </div>

              <ul
                style={{
                  margin: 0,
                  paddingLeft: '14px',
                  listStyle: 'none',
                }}
              >
                {(accordionShowMore ? pinnedCareerEntity.details : pinnedCareerEntity.details.slice(0, 3)).map((item, i) => (
                  <li
                    key={i}
                    style={{
                      fontSize: '12px',
                      color: 'var(--text-secondary)',
                      lineHeight: '1.5',
                      marginBottom: '4px',
                      display: 'flex',
                      gap: '8px',
                    }}
                  >
                    <span
                      style={{
                        display: 'inline-block',
                        width: '4px',
                        height: '4px',
                        borderRadius: '50%',
                        backgroundColor: pinnedCareerEntity.orgColor ?? 'var(--accent)',
                        opacity: 0.5,
                        flexShrink: 0,
                        marginTop: '7px',
                      }}
                    />
                    {item}
                  </li>
                ))}
              </ul>

              {accordionShowMore && (pinnedCareerEntity.outcomes ?? []).length > 0 && (
                <ul
                  style={{
                    margin: '8px 0 0',
                    paddingLeft: '14px',
                    listStyle: 'none',
                  }}
                >
                  {(pinnedCareerEntity.outcomes ?? []).map((item, i) => (
                    <li
                      key={i}
                      style={{
                        fontSize: '12px',
                        color: 'var(--text-tertiary)',
                        lineHeight: '1.5',
                        marginBottom: '4px',
                        display: 'flex',
                        gap: '8px',
                      }}
                    >
                      <span
                        style={{
                          display: 'inline-block',
                          width: '4px',
                          height: '4px',
                          borderRadius: '50%',
                          backgroundColor: 'var(--text-tertiary)',
                          opacity: 0.4,
                          flexShrink: 0,
                          marginTop: '7px',
                        }}
                      />
                      {item}
                  </li>
                ))}
              </ul>
              )}

              {pinnedCareerEntity.details.length > 3 && (
                <button
                  type="button"
                  onClick={() => setAccordionShowMore(prev => !prev)}
                  style={{
                    background: 'none',
                    border: 'none',
                    cursor: 'pointer',
                    padding: '4px 14px',
                    fontSize: '11px',
                    fontFamily: 'var(--font-geist-mono)',
                    color: pinnedCareerEntity.orgColor ?? 'var(--accent)',
                    fontWeight: 500,
                    marginTop: '4px',
                  }}
                >
                  {accordionShowMore ? 'Show less' : 'Show more'}
                </button>
              )}
            </div>
          </motion.div>
        )}
      </AnimatePresence>

      <p
        style={{
          position: 'absolute',
          width: 1,
          height: 1,
          padding: 0,
          margin: -1,
          overflow: 'hidden',
          clip: 'rect(0,0,0,0)',
          whiteSpace: 'nowrap',
          border: 0,
        }}
      >
        {srDescription}
      </p>

      <div
        role="group"
        aria-label="Career nodes - use Tab to navigate and Enter to open details"
        style={{
          position: 'absolute',
          top: 0,
          left: 0,
          width: '100%',
          height: '100%',
          pointerEvents: 'none',
        }}
      >
        {(() => {
          const domainOrder: Record<string, number> = { technical: 0, clinical: 1, leadership: 2 }
          const sorted = [...constellationNodes].sort((a, b) => {
            if (a.type === 'role' && b.type !== 'role') return -1
            if (a.type !== 'role' && b.type === 'role') return 1
            if (a.type === 'role' && b.type === 'role') {
              return (b.startYear ?? 0) - (a.startYear ?? 0)
            }
            const da = domainOrder[a.domain ?? 'technical'] ?? 0
            const db = domainOrder[b.domain ?? 'technical'] ?? 0
            if (da !== db) return da - db
            return (a.label ?? '').localeCompare(b.label ?? '')
          })
          return sorted
        })().map(node => {
          const yearRange = node.endYear
            ? `${node.startYear}-${node.endYear}`
            : `${node.startYear}-present`

          const position = nodeButtonPositions[node.id] ?? { x: dimensions.width * 0.5, y: dimensions.height * 0.5 }
          const mobileBtn = window.innerWidth < 640
          const btnSf = mobileBtn ? 1 : dimensions.scaleFactor
          const buttonWidth = node.type === 'role' ? (mobileBtn ? MOBILE_ROLE_WIDTH : Math.round(ROLE_WIDTH * btnSf)) : Math.round(34 * btnSf)
          const buttonHeight = node.type === 'role' ? Math.round(ROLE_HEIGHT * btnSf) : Math.round(34 * btnSf)

          return (
            <button
              key={node.id}
              type="button"
              aria-label={
                node.type === 'role'
                  ? `${node.label} at ${node.organization}, ${yearRange}. Press Enter to view details.`
                  : `${node.label} skill node. Press Enter to view details.`
              }
              style={{
                position: 'absolute',
                width: buttonWidth,
                height: buttonHeight,
                top: `${position.y}px`,
                left: `${position.x}px`,
                transform: 'translate(-50%, -50%)',
                background: 'transparent',
                border: 'none',
                cursor: 'default',
                pointerEvents: 'none',
                padding: 0,
                opacity: 0,
              }}
              onFocus={() => {
                setFocusedNodeId(node.id)
                highlightGraphRef.current?.(node.id)
                if (node.type === 'role') {
                  onNodeHover?.(node.id)
                }
              }}
              onBlur={() => {
                setFocusedNodeId(null)
                highlightGraphRef.current?.(resolveGraphFallback())
                onNodeHover?.(resolveRoleFallback())
              }}
              onClick={() => {
                setPinnedNodeId(node.id)
                pinnedNodeIdRef.current = node.id
                highlightGraphRef.current?.(node.id)
                if (node.type === 'role') {
                  onNodeHover?.(node.id)
                  onRoleClick(node.id)
                } else {
                  onNodeHover?.(resolveRoleFallback())
                  onSkillClick(node.id)
                }
              }}
              onKeyDown={e => handleNodeKeyDown(e, node.id, node.type)}
            />
          )
        })}
      </div>
    </div>
  )
}

export default CareerConstellation
