// A slowly drifting wireframe of the cross-repository graph: one point per
// repository that carries a multi-repo pattern, one line for every pattern two
// repositories share. Rendered rather than tabulated because entanglement is
// the one shape on the record a table cannot show.
//
// Deliberately flat and line-based - no lighting, no materials, no depth fog -
// so it reads as an oscilloscope trace rather than a product render.
//
// Degrades to nothing: no WebGL, no data, or reduced-motion preference all
// leave the surrounding markup untouched, which already carries the real
// numbers.

const POINT_SIZE = 5.5
const RADIUS = 26
const DRIFT = 0.00035

export default {
  mounted() {
    this.start()
  },

  updated() {
    // The graph is re-sent on registry changes; rebuild rather than mutate.
    this.stop()
    this.start()
  },

  // three.js is ~132 KB gzipped - four times the rest of the bundle combined.
  // Importing it dynamically keeps it in its own chunk, so only a visitor who
  // actually reaches a page with the field pays for it, and every other page
  // loads the same 43 KB it always did.
  async loadThree() {
    if (!window.__tarakanThree) {
      window.__tarakanThree = import("../../vendor/three.module.js")
    }
    return window.__tarakanThree
  },

  destroyed() {
    this.stop()
  },

  async start() {
    const graph = this.readGraph()
    if (!graph || graph.nodes.length < 2) return

    let THREE
    try {
      THREE = await this.loadThree()
    } catch (_error) {
      return
    }

    // The hook can be destroyed while the chunk is in flight.
    if (!this.el.isConnected) return
    this.THREE = THREE

    this.graph = graph
    this.draw()

    // The field is hidden below the lg breakpoint, so at mount it can have no
    // size at all and there is nothing to render into. Watch for it gaining
    // one instead of giving up permanently.
    if (!this.renderer && "ResizeObserver" in window) {
      this.observer = new ResizeObserver(() => {
        if (!this.renderer && this.el.clientWidth > 0 && this.el.clientHeight > 0) {
          this.draw()
        }
      })
      this.observer.observe(this.el)
    }
  },

  draw() {
    if (!this.graph || !this.THREE) return

    if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
      this.renderStill(this.graph)
    } else {
      this.renderAnimated(this.graph)
    }
  },

  readGraph() {
    try {
      const raw = this.el.dataset.graph
      if (!raw) return null
      const parsed = JSON.parse(raw)
      if (!Array.isArray(parsed.nodes) || !Array.isArray(parsed.edges)) return null
      return parsed
    } catch (_error) {
      return null
    }
  },

  // Colours come from the stylesheet so the field follows the theme toggle
  // instead of hard-coding a palette that only works in dark mode.
  token(name, fallback) {
    const value = getComputedStyle(this.el).getPropertyValue(name).trim()
    return value || fallback
  },

  build(graph) {
    const { nodes, edges } = graph

    // Fibonacci sphere: even coverage without clustering at the poles, and
    // deterministic, so the same record always draws the same figure.
    const positions = nodes.map((_node, i) => {
      const offset = 2 / nodes.length
      const y = i * offset - 1 + offset / 2
      const r = Math.sqrt(Math.max(0, 1 - y * y))
      const phi = i * Math.PI * (3 - Math.sqrt(5))
      return [Math.cos(phi) * r * RADIUS, y * RADIUS, Math.sin(phi) * r * RADIUS]
    })

    const group = new this.THREE.Group()

    const pointGeometry = new this.THREE.BufferGeometry()
    pointGeometry.setAttribute(
      "position",
      new this.THREE.Float32BufferAttribute(positions.flat(), 3)
    )
    group.add(
      new this.THREE.Points(
        pointGeometry,
        new this.THREE.PointsMaterial({
          color: new this.THREE.Color(this.token("--color-ink-faint", "#8a8f93")),
          size: POINT_SIZE,
          sizeAttenuation: false,
          transparent: true,
          opacity: 0.9
        })
      )
    )

    if (edges.length > 0) {
      const linePoints = []
      edges.forEach(([a, b]) => {
        if (positions[a] && positions[b]) linePoints.push(...positions[a], ...positions[b])
      })

      if (linePoints.length > 0) {
        const lineGeometry = new this.THREE.BufferGeometry()
        lineGeometry.setAttribute(
          "position",
          new this.THREE.Float32BufferAttribute(linePoints, 3)
        )
        group.add(
          new this.THREE.LineSegments(
            lineGeometry,
            new this.THREE.LineBasicMaterial({
              color: new this.THREE.Color(this.token("--color-phosphor", "#4ade80")),
              transparent: true,
              opacity: 0.42
            })
          )
        )
      }
    }

    return group
  },

  setup(graph) {
    const width = this.el.clientWidth
    const height = this.el.clientHeight
    if (width === 0 || height === 0) return null

    let renderer
    try {
      renderer = new this.THREE.WebGLRenderer({ alpha: true, antialias: true })
    } catch (_error) {
      return null
    }

    renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
    renderer.setSize(width, height, false)
    renderer.domElement.setAttribute("aria-hidden", "true")
    this.el.appendChild(renderer.domElement)

    const scene = new this.THREE.Scene()
    const camera = new this.THREE.PerspectiveCamera(45, width / height, 1, 400)
    camera.position.z = 78

    const group = this.build(graph)
    scene.add(group)

    this.renderer = renderer
    this.scene = scene
    this.camera = camera
    this.group = group

    this.onResize = () => {
      const w = this.el.clientWidth
      const h = this.el.clientHeight
      if (w === 0 || h === 0) return
      camera.aspect = w / h
      camera.updateProjectionMatrix()
      renderer.setSize(w, h, false)
      renderer.render(scene, camera)
    }
    window.addEventListener("resize", this.onResize)

    return { renderer, scene, camera, group }
  },

  renderStill(graph) {
    const context = this.setup(graph)
    if (!context) return
    context.group.rotation.set(0.35, 0.6, 0)
    context.renderer.render(context.scene, context.camera)
  },

  renderAnimated(graph) {
    const context = this.setup(graph)
    if (!context) return

    const { renderer, scene, camera, group } = context
    let last = performance.now()

    const frame = (now) => {
      // Rotation is time-based, not frame-based, so a 120Hz display does not
      // spin twice as fast as a 60Hz one.
      const delta = now - last
      last = now
      group.rotation.y += DRIFT * delta
      group.rotation.x = Math.sin(now * 0.00007) * 0.22
      renderer.render(scene, camera)
      this.frameId = requestAnimationFrame(frame)
    }

    this.frameId = requestAnimationFrame(frame)

    // A hidden tab still fires rAF in some browsers; stop drawing when the
    // page is not visible so a background tab costs nothing.
    this.onVisibility = () => {
      if (document.hidden) {
        this.cancelFrame()
      } else if (!this.frameId) {
        last = performance.now()
        this.frameId = requestAnimationFrame(frame)
      }
    }
    document.addEventListener("visibilitychange", this.onVisibility)
  },

  cancelFrame() {
    if (this.frameId) {
      cancelAnimationFrame(this.frameId)
      this.frameId = null
    }
  },

  stop() {
    this.cancelFrame()

    if (this.observer) {
      this.observer.disconnect()
      this.observer = null
    }

    if (this.onResize) {
      window.removeEventListener("resize", this.onResize)
      this.onResize = null
    }

    if (this.onVisibility) {
      document.removeEventListener("visibilitychange", this.onVisibility)
      this.onVisibility = null
    }

    // GPU buffers are not garbage collected with the JS objects that own them,
    // and LiveView navigation destroys hooks constantly.
    if (this.group) {
      this.group.traverse((child) => {
        if (child.geometry) child.geometry.dispose()
        if (child.material) child.material.dispose()
      })
      this.group = null
    }

    if (this.renderer) {
      this.renderer.dispose()
      this.renderer.domElement.remove()
      this.renderer = null
    }

    this.scene = null
    this.camera = null
    this.graph = null
  }
}
