# frozen_string_literal: true
require 'sketchup.rb'
require 'socket'
require 'json'
require 'tmpdir'
require 'date'

module Tarnus
module PLYGPUOverlay
  extend self
  HOST='127.0.0.1'.freeze
  PORT=17894
  OCCLUDER_PORT = 17895
  SNAP_PORT = 17896
  @udp=nil
  @timer=nil
  @snap_timer=nil
  @snap_socket=nil
  @snap_thread=nil
  @snap_point_m=nil
  @snap_request_id=0
  @snap_dirty=false
  @snap_result_id=0
  @camera_seq=0
  @cloud_path=nil
  @point_size=2.0
  @max_points=5_000_000
  @visible=true
  PREF_KEY='tarnus_ply_gpu_overlay_v2'.freeze
  RELEASE_EXPIRES = Date.new(2026, 10, 14).freeze
  @renderer_pid=nil
  @transform={tx:0.0,ty:0.0,tz:0.0,rx:0.0,ry:0.0,rz:0.0,scale:1.0}
  @native_snap_bridge=false
  @snap_radius=10.0
  @snap_proxy_screen_error=nil
  @occ_patch_seq=0

  def renderer_exe
    File.join(__dir__,'renderer','FGIdPointCloudRenderer.exe')
  end

  def release_expired?
    Date.today > RELEASE_EXPIRES
  end

  def ensure_release_active
    return true unless release_expired?
    UI.messagebox("FGId Point Cloud - version d'essai expirée le #{RELEASE_EXPIRES.strftime('%d/%m/%Y')}.")
    false
  end

  def renderer_log_path
    File.join(Dir.tmpdir, 'fgid_point_cloud_renderer_release.log')
  end

  def ruby_log_path
    File.join(Dir.tmpdir, 'fgid_point_cloud_ruby_release.log')
  end

  def python_probe_path
    File.join(Dir.tmpdir, 'ply_gpu_overlay_python_probe_v3_15_0.txt')
  end

  def dependency_probe_path
    File.join(Dir.tmpdir, 'ply_gpu_overlay_dependencies_probe_v3_15_0.txt')
  end


  def append_ruby_log(message)
    line = "[#{Time.now.strftime('%Y-%m-%d %H:%M:%S')}] #{message}"
    puts "[PLY GPU V3.15.9] #{message}"
    begin
      File.open(ruby_log_path, 'a') { |f| f.puts(line) }
    rescue
    end
  end

  def log(message)
    append_ruby_log(message)
  end

  def choose_cloud
    return unless ensure_release_active
    path=UI.openpanel('Select Point Cloud (PLY / LAS / LAZ / E57)',nil,'Point clouds|*.ply;*.las;*.laz;*.e57||')
    return unless path
    ext=File.extname(path).downcase
    unless File.file?(path) && ['.ply','.las','.laz','.e57'].include?(ext)
      UI.messagebox('Please select a valid .ply, .las, .laz or .e57 file.')
      return
    end

    # A single renderer owns the fixed local ports.  Replacing a cloud must
    # therefore stop the previous renderer first, otherwise the new process can
    # start while the old overlay still owns UDP/TCP 17894/17895/17896.
    if renderer_running?
      log("Open PCD: replacing renderer PID #{@renderer_pid}")
      stop_renderer(kill_process: true, clear_cloud: false)
    end

    @cloud_path=path
    # Give Windows/GLFW a very short moment to release the overlay HWND and
    # listening sockets before starting the replacement process.
    UI.start_timer(0.35, false) { start_renderer }
  end

  def model_center_m
    bb = Sketchup.active_model.bounds
    c = bb.center
    [
      c.x.to_f * 0.0254,
      c.y.to_f * 0.0254,
      c.z.to_f * 0.0254
    ]
  rescue => e
    log("Model center warning: #{e.class}: #{e.message}")
    [0.0, 0.0, 0.0]
  end

  def entity_visible_for_occlusion?(entity)
    return false if entity.respond_to?(:hidden?) && entity.hidden?
    if entity.respond_to?(:layer) && entity.layer
      return false unless entity.layer.visible?
    end
    true
  rescue
    true
  end

  def collect_occlusion_triangles(entities, transform, out)
    entities.each do |entity|
      next unless entity_visible_for_occlusion?(entity)

      case entity
      when Sketchup::Face
        mesh = entity.mesh(7)
        points = mesh.points

        mesh.polygons.each do |poly|
          ids = poly.map { |idx| idx.abs - 1 }
          next if ids.length < 3

          p0 = transform * points[ids[0]]
          (1...(ids.length - 1)).each do |i|
            p1 = transform * points[ids[i]]
            p2 = transform * points[ids[i + 1]]

            [p0, p1, p2].each do |p|
              out << (p.x.to_f * 0.0254)
              out << (p.y.to_f * 0.0254)
              out << (p.z.to_f * 0.0254)
            end
          end
        end

      when Sketchup::Group
        collect_occlusion_triangles(
          entity.entities,
          transform * entity.transformation,
          out
        )

      when Sketchup::ComponentInstance
        collect_occlusion_triangles(
          entity.definition.entities,
          transform * entity.transformation,
          out
        )
      end
    end
  end

  # --- V3.11 chunked occlusion -------------------------------------------------
  # Each root Group/ComponentInstance owns one persistent chunk identified by
  # persistent_id. Root loose faces are kept in chunk 0. During Move/Rotate we
  # only rebuild and send the affected chunks instead of traversing the whole model.
  def collect_loose_root_faces(out)
    Sketchup.active_model.entities.each do |entity|
      next unless entity.is_a?(Sketchup::Face)
      next unless entity_visible_for_occlusion?(entity)
      mesh = entity.mesh(7)
      points = mesh.points
      mesh.polygons.each do |poly|
        ids = poly.map { |idx| idx.abs - 1 }
        next if ids.length < 3
        p0 = points[ids[0]]
        (1...(ids.length - 1)).each do |i|
          [p0, points[ids[i]], points[ids[i + 1]]].each do |p|
            out << p.x.to_f * 0.0254
            out << p.y.to_f * 0.0254
            out << p.z.to_f * 0.0254
          end
        end
      end
    end
  end

  def root_chunk_key(entity)
    return 0 unless entity
    return entity.persistent_id.to_i if entity.respond_to?(:persistent_id)
    entity.entityID.to_i
  rescue
    0
  end

  def build_occlusion_chunk_for_root(entity)
    floats=[]
    if entity.nil?
      collect_loose_root_faces(floats)
      return [0, floats]
    end
    return [root_chunk_key(entity),floats] unless entity_visible_for_occlusion?(entity)
    case entity
    when Sketchup::Group
      collect_occlusion_triangles(entity.entities, entity.transformation, floats)
    when Sketchup::ComponentInstance
      collect_occlusion_triangles(entity.definition.entities, entity.transformation, floats)
    when Sketchup::Face
      # Root loose geometry is grouped into chunk 0.
      collect_loose_root_faces(floats)
      return [0, floats]
    end
    [root_chunk_key(entity),floats]
  rescue => e
    log("Occluder chunk build failed: #{e.class}: #{e.message}")
    [root_chunk_key(entity),[]]
  end

  def root_occlusion_entities
    Sketchup.active_model.entities.select do |e|
      (e.is_a?(Sketchup::Group) || e.is_a?(Sketchup::ComponentInstance)) &&
        !e.get_attribute('FGIdPointCloud','snap_bridge',false)
    end
  rescue
    []
  end

  def encode_occluder_chunks(chunks)
    payload=[chunks.length].pack('L<')
    chunks.each do |key,floats|
      data=floats.pack('e*')
      payload << [key.to_i].pack('Q<')
      payload << [floats.length].pack('L<')
      payload << data
    end
    payload
  end

  def send_occluder_chunks(reset: false, roots: nil)
    chunks=[]
    if reset
      chunks << build_occlusion_chunk_for_root(nil)
      root_occlusion_entities.each { |e| chunks << build_occlusion_chunk_for_root(e) }
    else
      list=Array(roots).compact.uniq
      list.each { |e| chunks << build_occlusion_chunk_for_root(e) unless e == :loose }
      chunks << build_occlusion_chunk_for_root(nil) if list.include?(:loose)
    end
    return if chunks.empty?
    payload=encode_occluder_chunks(chunks)
    magic=reset ? 'OCR2'.b : 'OCU2'.b
    header=magic + [payload.bytesize].pack('L<')
    socket=nil
    begin
      socket=TCPSocket.new(HOST,OCCLUDER_PORT)
      socket.write(header); socket.write(payload); socket.flush
      tris=chunks.sum { |_k,f| f.length / 9 }
      log("Occluder #{reset ? 'RESET' : 'PATCH'} sent: #{chunks.length} chunks / #{tris} triangles")
    rescue => e
      log("Occluder chunk send failed: #{e.class}: #{e.message}")
    ensure
      socket.close if socket
    end
  end

  def send_occluder_mesh_direct
    send_occluder_chunks(reset: true)
  end

  def schedule_occluder_update
    return if @occluder_update_pending
    @occluder_update_pending=true
    UI.start_timer(0.05,false) do
      @occluder_update_pending=false
      send_occluder_chunks(reset: true)
    end
  end

  def suppress_occluder_observer?
    !!@suppress_occluder_observer
  end

  class OccluderModelObserver < Sketchup::ModelObserver
    def onTransactionCommit(model)
      Tarnus::PLYGPUOverlay.schedule_occluder_update unless Tarnus::PLYGPUOverlay.suppress_occluder_observer?
    end

    def onTransactionUndo(model)
      Tarnus::PLYGPUOverlay.schedule_occluder_update unless Tarnus::PLYGPUOverlay.suppress_occluder_observer?
    end

    def onTransactionRedo(model)
      Tarnus::PLYGPUOverlay.schedule_occluder_update unless Tarnus::PLYGPUOverlay.suppress_occluder_observer?
    end
  end

  def attach_model_observer
    @model_observer ||= OccluderModelObserver.new
    Sketchup.active_model.add_observer(@model_observer)
    log('ModelObserver attached for direct occluder updates')
  rescue => e
    log("ModelObserver attach failed: #{e.class}: #{e.message}")
  end

  def detach_model_observer
    return unless @model_observer
    begin
      Sketchup.active_model.remove_observer(@model_observer)
    rescue
    end
  end

  def start_renderer
    return UI.messagebox('Select a point cloud first.') unless @cloud_path
    return unless ensure_release_active

    exe = renderer_exe
    unless File.file?(exe)
      UI.messagebox("Renderer not found:\n#{exe}")
      return
    end

    log("=== START FGId Point Cloud RELEASE V3.15.9 ===")
    log("Renderer EXE: #{exe}")
    log("Cloud: #{@cloud_path}")
    log("Renderer log: #{renderer_log_path}")

    begin
      File.delete(renderer_log_path) if File.exist?(renderer_log_path)
    rescue
    end

    center_m = model_center_m
    log("SketchUp model center (m): #{center_m.inspect}")

    args = [
      exe,
      '--cloud', @cloud_path,
      '--port', PORT.to_s,
      '--sketchup-pid', Process.pid.to_s,
      '--point-size', @point_size.to_s,
      '--max-points', @max_points.to_s,
      '--log', renderer_log_path,
      '--align-center', center_m[0].to_s, center_m[1].to_s, center_m[2].to_s,
      '--occluder-port', OCCLUDER_PORT.to_s,
      '--snap-port', SNAP_PORT.to_s
    ]

    begin
      @renderer_pid = spawn(*args, new_pgroup: true)
      Process.detach(@renderer_pid)
      log("Renderer EXE started PID #{@renderer_pid}")
    rescue => e
      log("Renderer launch failed: #{e.class}: #{e.message}")
      UI.messagebox("Unable to launch renderer:\n#{e.class}: #{e.message}")
      return
    end

    UI.start_timer(0.8, false) do
      start_camera_stream
      attach_model_observer
    end

    UI.start_timer(1.4, false) do
      send_occluder_mesh_direct
      start_live_occlusion_monitor
    end

    UI.start_timer(2.0, false) do
      if File.exist?(renderer_log_path)
        log("Renderer log exists after 2s, size=#{File.size(renderer_log_path)}")
      else
        log("Renderer log does NOT exist after 2s")
      end
    end
  end

  class CameraViewObserver < Sketchup::ViewObserver
    def onViewChanged(view)
      Tarnus::PLYGPUOverlay.send_camera_from_view_observer(view)
    rescue => e
      puts "[PLY GPU V3.15.9] ViewObserver error: #{e.class}: #{e.message}"
    end
  end

  def attach_view_observer
    view = Sketchup.active_model.active_view

    if @observed_view && @view_observer
      begin
        @observed_view.remove_observer(@view_observer)
      rescue
      end
    end

    @view_observer ||= CameraViewObserver.new
    @observed_view = view
    status = view.add_observer(@view_observer)
    log("ViewObserver attached: #{status.inspect}")
  rescue => e
    log("ViewObserver attach failed: #{e.class}: #{e.message}")
  end

  def detach_view_observer
    if @observed_view && @view_observer
      begin
        @observed_view.remove_observer(@view_observer)
      rescue
      end
    end
    @observed_view = nil
  end

  def send_camera_from_view_observer(view)
    # Keep the renderer fed continuously while Orbit/Pan/Zoom is active.
    # ViewObserver is event-driven; the high-rate timer below remains a safety net.
    now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
    return if (now - @last_view_send.to_f) < (1.0 / 240.0)
    @last_view_send = now

    send_camera_packet
  end

  def start_camera_stream
    @udp ||= UDPSocket.new
    attach_view_observer
    return if @timer
    @timer=UI.start_timer(1.0/120.0,true){ send_camera_packet }
    log('Camera stream started at 120 Hz + ViewObserver')
  end

  def renderer_running?
    pid = @renderer_pid.to_i
    return false if pid <= 0
    begin
      Process.kill(0, pid)
      true
    rescue Errno::ESRCH, Errno::EINVAL
      @renderer_pid=nil
      false
    rescue Errno::EPERM
      true
    rescue
      false
    end
  end

  def kill_renderer_process
    pid = @renderer_pid.to_i
    return if pid <= 0

    begin
      Process.kill('KILL', pid)
      log("Renderer process killed PID #{pid}")
    rescue Errno::ESRCH
      log("Renderer PID #{pid} already stopped")
    rescue => e
      log("Process.kill failed for PID #{pid}: #{e.class}: #{e.message}")
      # Windows fallback.  Shell redirection prevents taskkill output from
      # polluting SketchUp's console.  This is only used if Process.kill fails.
      begin
        system(%(taskkill /PID #{pid} /T /F >NUL 2>&1))
        log("taskkill fallback issued for PID #{pid}")
      rescue => e2
        log("taskkill fallback failed: #{e2.class}: #{e2.message}")
      end
    ensure
      @renderer_pid=nil
    end
  end

  def stop_renderer(kill_process: false, clear_cloud: false)
    if @timer
      UI.stop_timer(@timer) rescue nil
      @timer=nil
    end
    stop_live_occlusion_monitor
    disable_native_snap_bridge(false)

    # Ask the renderer to close cleanly first.  The explicit kill below is the
    # guarantee required by Unload PCD and by replacing the current cloud.
    send_packet(type:'quit') rescue nil
    detach_view_observer
    detach_model_observer
    if @snap_timer
      UI.stop_timer(@snap_timer) rescue nil
      @snap_timer=nil
    end
    begin
      @snap_socket.close if @snap_socket
    rescue
    end
    @snap_socket=nil
    @snap_thread=nil
    @snap_point_m=nil

    if kill_process && @renderer_pid
      # Small grace period lets the normal quit packet destroy GLFW first.
      sleep(0.12)
      kill_renderer_process if renderer_running?
      @renderer_pid=nil
    end

    @cloud_path=nil if clear_cloud
  end

  def unload_cloud
    return unless ensure_release_active
    if @renderer_pid || @cloud_path
      log("Unload PCD requested (PID #{@renderer_pid.inspect})")
      stop_renderer(kill_process: true, clear_cloud: true)
      Sketchup.set_status_text('FGId Point Cloud: nuage déchargé.', SB_PROMPT) rescue nil
    else
      Sketchup.set_status_text('FGId Point Cloud: aucun nuage chargé.', SB_PROMPT) rescue nil
    end
  end

  def camera_hash
    view = Sketchup.active_model.active_view
    cam = view.camera
    to_m = lambda { |p| [p.x.to_f * 0.0254, p.y.to_f * 0.0254, p.z.to_f * 0.0254] }
    vec = lambda { |v| [v.x.to_f, v.y.to_f, v.z.to_f] }

    data = {
      eye: to_m.call(cam.eye),
      target: to_m.call(cam.target),
      up: vec.call(cam.up),
      perspective: cam.perspective?,
      fov_is_height: (cam.respond_to?(:fov_is_height?) ? cam.fov_is_height? : true),
      vpwidth: view.vpwidth.to_i,
      vpheight: view.vpheight.to_i
    }

    # SketchUp warns if Camera#fov is queried in parallel projection.
    # SketchUp warns if Camera#height is queried in perspective projection.
    if cam.perspective?
      data[:fov] = cam.fov.to_f
      data[:ortho_height] = nil
    else
      data[:fov] = nil
      data[:ortho_height] = cam.height.to_f * 0.0254
    end

    data
  end

  def send_camera_packet
    @camera_seq = @camera_seq.to_i + 1
    send_packet(type:'camera',seq:@camera_seq,sent_at:Process.clock_gettime(Process::CLOCK_MONOTONIC),camera:camera_hash,visible:@visible,point_size:@point_size,transform:@transform)
  rescue => e
    log("Camera stream error: #{e.class}: #{e.message}")
  end

  def send_packet(payload)
    @udp ||= UDPSocket.new
    @udp.send(JSON.generate(payload),0,HOST,PORT)
  rescue
  end

  def start_snap_receiver
    return if @snap_thread && @snap_thread.alive?

    begin
      @snap_socket.close if @snap_socket
    rescue
    end

    @snap_socket = UDPSocket.new
    @snap_socket.bind(HOST, SNAP_PORT)
    @snap_thread = Thread.new do
      loop do
        begin
          raw, = @snap_socket.recvfrom(65_535)
          msg = JSON.parse(raw)
          next unless msg['type'] == 'snap_result'
          if msg['hit'] && msg['point'].is_a?(Array) && msg['point'].length >= 3
            @snap_point_m = msg['point'][0, 3].map(&:to_f)
            @last_snap_cursor_x = msg['cursor_x'].to_f if msg.key?('cursor_x')
            @last_snap_cursor_y = msg['cursor_y'].to_f if msg.key?('cursor_y')
          else
            @snap_point_m = nil
          end
          @snap_result_id = msg['request_id'].to_i
          @snap_dirty = true
        rescue IOError, Errno::EBADF
          break
        rescue => e
          log("Snap receiver warning: #{e.class}: #{e.message}")
          sleep(0.05)
        end
      end
    end

    # The Python renderer now sends a snap result only when mouse/camera moved.
    # Polling Ruby at 30 Hz is enough to consume these results without forcing
    # SketchUp to redraw continuously when nothing changed.
    @snap_timer ||= UI.start_timer(1.0 / 30.0, true) do
      begin
        if @snap_dirty
          @snap_dirty = false
          update_snap_bridge if @native_snap_bridge
          Sketchup.active_model.active_view.invalidate
        end
      rescue
      end
    end
    log("Snap receiver listening on UDP #{SNAP_PORT}")
  rescue => e
    log("Snap receiver start failed: #{e.class}: #{e.message}")
  end

  def snap_point_inches
    p = @snap_point_m
    return nil unless p && p.length >= 3
    Geom::Point3d.new(p[0] / 0.0254, p[1] / 0.0254, p[2] / 0.0254)
  end

  def request_snap(x, y, radius_px = 9)
    @snap_request_id = @snap_request_id.to_i + 1
    send_packet(
      type: 'snap_pick',
      request_id: @snap_request_id,
      x: x.to_i,
      y: y.to_i,
      radius_px: radius_px.to_f,
      reply_port: SNAP_PORT
    )
  end

  class PointCloudSnapTool
    def initialize
      @last_send = 0.0
      @cursor_x = 0
      @cursor_y = 0
    end

    def activate
      Sketchup.set_status_text(
        'Point Cloud Snap: move over a cloud point, click to create a guide point. Esc exits.',
        SB_PROMPT
      )
    end

    def deactivate(view)
      Tarnus::PLYGPUOverlay.clear_snap
      view.invalidate
    end

    def onCancel(reason, view)
      Sketchup.active_model.select_tool(nil)
    end

    def onMouseMove(flags, x, y, view)
      @cursor_x = x
      @cursor_y = y
      now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
      return if (now - @last_send) < (1.0 / 45.0)
      @last_send = now
      Tarnus::PLYGPUOverlay.request_snap(x, y, 9)
    end

    def onLButtonDown(flags, x, y, view)
      point = Tarnus::PLYGPUOverlay.snap_point_inches
      return UI.beep unless point

      model = Sketchup.active_model
      model.start_operation('Point Cloud Snap Guide', true)
      model.active_entities.add_cpoint(point)
      model.commit_operation
      Sketchup.set_status_text('Guide point created from point cloud snap.', SB_PROMPT)
      view.invalidate
    rescue => e
      model.abort_operation rescue nil
      Tarnus::PLYGPUOverlay.log("Snap guide creation failed: #{e.class}: #{e.message}")
    end

    def draw(view)
      point = Tarnus::PLYGPUOverlay.snap_point_inches
      return unless point
      view.drawing_color = 'magenta'
      view.draw_points([point], 14, 3, 'magenta')
      screen = view.screen_coords(point)
      view.draw_text([screen.x + 12, screen.y + 12], 'POINT CLOUD')
    end

    def getExtents
      bb = Geom::BoundingBox.new
      p = Tarnus::PLYGPUOverlay.snap_point_inches
      bb.add(p) if p
      bb
    end
  end

  def activate_snap_tool
    start_snap_receiver
    Sketchup.active_model.select_tool(PointCloudSnapTool.new)
  end

  def clear_snap
    @snap_point_m = nil
  end

  def change_point_size(delta)
    return unless ensure_release_active
    @point_size = [[@point_size.to_f + delta.to_f, 1.0].max, 32.0].min
    send_packet(type:'settings', point_size:@point_size)
    Sketchup.set_status_text("Point size: #{@point_size.round(1)} px", SB_PROMPT)
  end

  def increase_point_size
    change_point_size(1.0)
  end

  def decrease_point_size
    change_point_size(-1.0)
  end

  def overlay_settings
    r=UI.inputbox(['Point size (px)','Max rendered points','Visible?'],[@point_size,@max_points,@visible ? 'Yes':'No'],['','','Yes|No'],'PLY GPU Overlay')
    return unless r
    @point_size=[[r[0].to_f,1.0].max,32.0].min
    @max_points=[[r[1].to_i,10_000].max,50_000_000].min
    @visible=r[2].to_s=='Yes'
    send_packet(type:'settings',point_size:@point_size,max_points:@max_points,visible:@visible)
  end

  # --- V3.11 live occlusion monitor ---------------------------------------
  # Fast path: selected root groups/components and the active edit-context root
  # are patched independently at up to 45 Hz. A slower fallback signature catches
  # loose-face topology changes and performs a complete chunk reset when needed.
  def transformation_signature(tr)
    tr.to_a.map { |v| (v.to_f * 10_000_000.0).round }.join(',')
  rescue
    ''
  end

  def bounds_signature(bb)
    return '' unless bb && bb.valid?
    [bb.min.x,bb.min.y,bb.min.z,bb.max.x,bb.max.y,bb.max.z].map { |v| (v.to_f*10_000.0).round }.join(',')
  rescue
    ''
  end

  def live_entity_signature(entity, depth=0)
    return nil unless entity && entity.valid?
    return nil if entity.get_attribute('FGIdPointCloud','snap_bridge',false)
    pid=(entity.respond_to?(:persistent_id) ? entity.persistent_id : entity.entityID)
    case entity
    when Sketchup::Group, Sketchup::ComponentInstance
      [pid,entity.class.name,transformation_signature(entity.transformation),bounds_signature(entity.bounds)].join(':')
    when Sketchup::Face, Sketchup::Edge
      [pid,entity.class.name,bounds_signature(entity.bounds)].join(':')
    else
      nil
    end
  rescue
    nil
  end

  def dynamic_occlusion_roots
    model=Sketchup.active_model
    roots=[]
    # If editing inside a group/component, updating the first instance in the
    # active path refreshes that whole root chunk in world coordinates.
    path=model.active_path rescue nil
    roots << path.first if path && path.first && path.first.valid?

    model.selection.each do |e|
      if model.active_entities == model.entities
        if e.is_a?(Sketchup::Group) || e.is_a?(Sketchup::ComponentInstance)
          roots << e
        elsif e.is_a?(Sketchup::Face) || e.is_a?(Sketchup::Edge)
          roots << :loose
        end
      elsif path && path.first
        roots << path.first
      end
    end
    roots.reject! do |e|
      e != :loose && e.respond_to?(:get_attribute) && e.get_attribute('FGIdPointCloud','snap_bridge',false)
    end
    roots.uniq
  rescue
    []
  end

  def dynamic_roots_signature(roots)
    roots.map do |e|
      if e == :loose
        # Cheap loose-geometry signature from selected entities only.
        selected=Sketchup.active_model.selection.map { |x| live_entity_signature(x) }.compact
        "L:#{selected.join(';')}"
      else
        live_entity_signature(e)
      end
    end.compact.join('|')
  end

  def live_occlusion_signature
    model=Sketchup.active_model
    parts=[]
    model.entities.each do |e|
      sig=live_entity_signature(e)
      parts << sig if sig
    end
    if model.active_entities != model.entities
      model.active_entities.each do |e|
        sig=live_entity_signature(e)
        parts << "A:#{sig}" if sig
      end
    end
    parts.join('|')
  rescue => e
    log("Live occlusion signature warning: #{e.class}: #{e.message}")
    ''
  end

  def start_live_occlusion_monitor
    return if @live_occ_timer
    @last_dynamic_occ_signature=''
    @last_occ_patch_send=0.0
    @live_occ_timer=UI.start_timer(1.0/60.0,true) do
      begin
        now=Process.clock_gettime(Process::CLOCK_MONOTONIC)
        roots=dynamic_occlusion_roots
        dsig=dynamic_roots_signature(roots)
        if !roots.empty? && dsig != @last_dynamic_occ_signature
          @last_dynamic_occ_signature=dsig
          if (now-@last_occ_patch_send.to_f) >= (1.0/45.0)
            @last_occ_patch_send=now
            send_occluder_chunks(reset:false,roots:roots)
          end
        elsif roots.empty?
          @last_dynamic_occ_signature=''
        end
      rescue => e
        log("Live occlusion monitor warning: #{e.class}: #{e.message}")
      end
    end
    log('Live occlusion V3.15: 60 Hz monitor / PATCH max 45 Hz; full RESET only on Commit/Undo/Redo')
  end

  def stop_live_occlusion_monitor
    return unless @live_occ_timer
    UI.stop_timer(@live_occ_timer) rescue nil
    @live_occ_timer=nil
  end

  # --- Experimental native-tool snap bridge -------------------------------
  # The Ruby API cannot inject an external InputPoint into native tools.
  # Instead one construction point is kept inside a tiny group and the group
  # is moved with Group#move! (no Undo entry) to the latest GPU snap position.
  def ensure_snap_bridge_group
    return @snap_bridge_group if @snap_bridge_group && @snap_bridge_group.valid?
    model=Sketchup.active_model
    @suppress_occluder_observer=true
    group=model.entities.add_group
    group.name='FGId Point Cloud Snap Bridge'
    group.set_attribute('FGIdPointCloud','snap_bridge',true)

    # V3.13.1: use a real, strong inference vertex.  The definition is built
    # at a convenient 1-inch size and the instance is dynamically scaled so
    # the six arms stay ~8 screen pixels long whatever the current zoom.
    # This is much more reliable than the previous fixed 1.5 mm micro-vertex
    # which could become sub-pixel and effectively disappear from native picks.
    d = 1.0 # inch in SketchUp internal units
    ents = group.entities
    ents.add_cpoint(ORIGIN)
    [
      [ d, 0, 0],[-d, 0, 0],
      [ 0, d, 0],[ 0,-d, 0],
      [ 0, 0, d],[ 0, 0,-d]
    ].each { |v| ents.add_line(ORIGIN, Geom::Point3d.new(*v)) }
    group.set_attribute('FGIdPointCloud','snap_proxy_vertex',true)
    group.locked = true rescue nil
    @snap_bridge_group=group
    @suppress_occluder_observer=false
    group
  rescue => e
    @suppress_occluder_observer=false
    log("Snap bridge creation failed: #{e.class}: #{e.message}")
    nil
  end

  def update_snap_bridge
    return unless @native_snap_bridge
    g=ensure_snap_bridge_group
    return unless g
    p=snap_point_inches
    if p
      view=Sketchup.active_model.active_view
      # Keep the proxy large enough to participate in native inference.
      # pixels_to_model returns SketchUp internal length units (inches).
      arm=view.pixels_to_model(10.0,p).to_f rescue 0.05
      arm=0.001 if arm < 0.001
      scale=arm # definition arms are exactly 1 inch long
      tr=Geom::Transformation.translation(p.to_a) * Geom::Transformation.scaling(scale)
      @suppress_occluder_observer=true
      g.hidden=false if g.hidden?
      g.move!(tr)
      @suppress_occluder_observer=false

      # Diagnostic: if this is large, the external renderer and SketchUp do not
      # agree on projection, and no native inference bridge can work reliably.
      begin
        sc=view.screen_coords(p)
        cx=@last_snap_cursor_x
        cy=@last_snap_cursor_y
        if cx && cy
          @snap_proxy_screen_error=Math.sqrt((sc.x-cx)**2 + (sc.y-cy)**2)
        end
      rescue
        @snap_proxy_screen_error=nil
      end

      # V3.15.9 PASSIVE SNAP: never synthesize mouse/keyboard input and never
      # move the physical cursor.  SketchUp receives all native input directly.
      # The GPU candidate is accepted only inside a tight radius, then this real
      # proxy vertex sits close enough to the physical cursor for SketchUp's own
      # inference engine to acquire it naturally.
      view.invalidate rescue nil
    else
      # Do not toggle hidden state on every miss: that generated SketchUp
      # transactions and caused full occluder RESET storms. Move the proxy far
      # away instead; Group#move! does not create an Undo transaction.
      @suppress_occluder_observer=true
      g.move!(Geom::Transformation.translation([1_000_000.0,1_000_000.0,1_000_000.0]))
      @suppress_occluder_observer=false
      @snap_proxy_screen_error=nil
    end
  rescue => e
    @suppress_occluder_observer=false
    log("Snap bridge move failed: #{e.class}: #{e.message}")
  end

  def native_snap_enabled?
    !!@native_snap_bridge
  end

  def enable_native_snap_bridge
    start_snap_receiver
    @native_snap_bridge=true
    @snap_point_m=nil
    send_packet(type:'settings',auto_snap:true,snap_reply_port:SNAP_PORT,snap_radius:@snap_radius)
    log("Cloud snapping ENABLED V3.15.9 direct-tool mode, radius=#{@snap_radius}px")
    Sketchup.set_status_text('Accrochage PCD : ON - utilisé directement par les outils FGId PCD.',SB_PROMPT)
    Sketchup.active_model.active_view.invalidate rescue nil
  end

  def disable_native_snap_bridge(erase=true)
    @native_snap_bridge=false
    @snap_point_m=nil
    send_packet(type:'settings',auto_snap:false,snap_reply_port:SNAP_PORT) rescue nil
    if @snap_bridge_group && @snap_bridge_group.valid?
      @suppress_occluder_observer=true
      @snap_bridge_group.erase! rescue nil
      @snap_bridge_group=nil
      @suppress_occluder_observer=false
    end
    log('Cloud snapping DISABLED')
    Sketchup.set_status_text('Accrochage PCD : OFF',SB_PROMPT)
    Sketchup.active_model.active_view.invalidate rescue nil
  end

  def toggle_native_snap_bridge
    native_snap_enabled? ? disable_native_snap_bridge : enable_native_snap_bridge
  end

  # --- V3.15 direct PCD tools ---------------------------------------------
  # Native SketchUp tools cannot receive an external InputPoint through the Ruby
  # API.  These FGId tools consume the exact GPU-picked cloud coordinate directly,
  # while still using Sketchup::InputPoint as a fallback on normal model geometry.
  class PCDLineTool
    def initialize
      @first=nil
      @hover=nil
      @hover_kind=nil
      @native_ip=Sketchup::InputPoint.new
      @axis_lock=nil
    end

    def activate
      Tarnus::PLYGPUOverlay.enable_native_snap_bridge unless Tarnus::PLYGPUOverlay.native_snap_enabled?
      Sketchup.set_status_text('PCD Line : clique un point du nuage ou une inference SketchUp. Fleches = axe X/Y/Z, bas = liberer.',SB_PROMPT)
      Sketchup.active_model.active_view.invalidate
    end

    def deactivate(view)
      view.invalidate
    end

    def onCancel(reason,view)
      if @first
        @first=nil
        @axis_lock=nil
        Sketchup.set_status_text('PCD Line : premier point annule.',SB_PROMPT)
        view.invalidate
      else
        Sketchup.active_model.select_tool(nil)
      end
    end

    def onMouseMove(flags,x,y,view)
      Tarnus::PLYGPUOverlay.request_snap(x,y,10)
      @native_ip.pick(view,x,y)
      refresh_hover(view)
      view.invalidate
    end

    def onLButtonDown(flags,x,y,view)
      Tarnus::PLYGPUOverlay.request_snap(x,y,10)
      @native_ip.pick(view,x,y)
      refresh_hover(view)
      p=@hover
      return UI.beep unless p
      if @first.nil?
        @first=p
        Sketchup.set_status_text('PCD Line : clique le second point. Fleches = verrouillage axe.',SB_PROMPT)
      else
        p=apply_axis_lock(p)
        if p.distance(@first) > 1.0e-6
          model=Sketchup.active_model
          model.start_operation('FGId PCD Line',true)
          model.active_entities.add_line(@first,p)
          model.commit_operation
          Tarnus::PLYGPUOverlay.log("PCD Line created: #{@first.to_a.inspect} -> #{p.to_a.inspect}")
          @first=p
        else
          UI.beep
        end
      end
      view.invalidate
    rescue => e
      Sketchup.active_model.abort_operation rescue nil
      Tarnus::PLYGPUOverlay.log("PCD Line failed: #{e.class}: #{e.message}")
    end

    def onKeyDown(key,repeat,flags,view)
      # Windows virtual-key codes used by SketchUp Ruby tools.
      case key
      when 37 # left -> green/Y
        @axis_lock=:y
      when 38 # up -> blue/Z
        @axis_lock=:z
      when 39 # right -> red/X
        @axis_lock=:x
      when 40 # down -> release
        @axis_lock=nil
      when 27
        onCancel(0,view)
      end
      refresh_hover(view)
      view.invalidate
    end

    def draw(view)
      refresh_hover(view)
      if @hover
        sc=view.screen_coords(@hover)
        view.drawing_color = (@hover_kind == :pcd ? Sketchup::Color.new(255,0,220) : Sketchup::Color.new(0,120,255))
        view.line_width=2
        r=7
        pts=[]
        24.times do |i|
          a=2.0*Math::PI*i/24.0
          pts << Geom::Point3d.new(sc.x+Math.cos(a)*r,sc.y+Math.sin(a)*r,0)
        end
        view.draw2d(GL_LINE_LOOP,pts)
      end
      if @first && @hover
        p=apply_axis_lock(@hover)
        view.drawing_color=Sketchup::Color.new(255,0,220)
        view.line_width=2
        view.draw(GL_LINES,[@first,p])
      end
    end

    private

    def refresh_hover(view)
      pcd=Tarnus::PLYGPUOverlay.snap_point_inches
      if pcd
        # Reject stale/misaligned GPU results.  The exact cloud point must project
        # near the physical cursor; otherwise do not use it for geometry creation.
        begin
          sc=view.screen_coords(pcd)
          cx=Tarnus::PLYGPUOverlay.last_snap_cursor_x
          cy=Tarnus::PLYGPUOverlay.last_snap_cursor_y
          err=(cx && cy) ? Math.sqrt((sc.x-cx)**2+(sc.y-cy)**2) : 9999.0
          if err <= 14.0
            @hover=pcd
            @hover_kind=:pcd
            return
          end
        rescue
        end
      end
      if @native_ip && @native_ip.valid?
        @hover=@native_ip.position
        @hover_kind=:model
      else
        @hover=nil
        @hover_kind=nil
      end
    end

    def apply_axis_lock(p)
      return p unless @first && @axis_lock
      case @axis_lock
      when :x then Geom::Point3d.new(p.x,@first.y,@first.z)
      when :y then Geom::Point3d.new(@first.x,p.y,@first.z)
      when :z then Geom::Point3d.new(@first.x,@first.y,p.z)
      else p
      end
    end
  end

  def last_snap_cursor_x; @last_snap_cursor_x; end
  def last_snap_cursor_y; @last_snap_cursor_y; end

  def activate_pcd_line_tool
    start_snap_receiver
    enable_native_snap_bridge unless native_snap_enabled?
    Sketchup.active_model.select_tool(PCDLineTool.new)
  end

  def transform_settings
    r=UI.inputbox(['Translation X (m)','Translation Y (m)','Translation Z (m)','Rotation X (deg)','Rotation Y (deg)','Rotation Z (deg)','Scale'],
      [@transform[:tx],@transform[:ty],@transform[:tz],@transform[:rx],@transform[:ry],@transform[:rz],@transform[:scale]],'Point Cloud Transform')
    return unless r
    @transform={tx:r[0].to_f,ty:r[1].to_f,tz:r[2].to_f,rx:r[3].to_f,ry:r[4].to_f,rz:r[5].to_f,scale:r[6].to_f}
    send_packet(type:'transform',transform:@transform)
  end

  def overlay_margins
    r=UI.inputbox(['Left margin (px)','Top margin (px)','Right margin (px)','Bottom margin (px)'],[0,100,0,0],'Overlay Margins')
    return unless r
    send_packet(type:'margins',margins:{left:r[0].to_i,top:r[1].to_i,right:r[2].to_i,bottom:r[3].to_i})
  end

  def open_renderer_log
    paths = [ruby_log_path, renderer_log_path]
    existing = paths.select { |p| File.exist?(p) }

    if existing.empty?
      UI.messagebox("No log file exists yet.\n\nRun Diagnostics first.")
      return
    end

    existing.each do |path|
      UI.openURL("file:///#{path.tr('\\','/')}")
    end
  end

  def toggle_visibility
    @visible=!@visible
    send_packet(type:'settings',visible:@visible)
  end

  unless file_loaded?(__FILE__)
    menu=UI.menu('Extensions').add_submenu('FGId Point Cloud - Trial')
    load_menu_id = menu.add_item('Ouvrir un nuage de points...'){ choose_cloud }
    unload_menu_id = menu.add_item('Décharger le nuage de points'){ unload_cloud }
    plus_menu_id = menu.add_item('Grossir les points'){ increase_point_size }
    minus_menu_id = menu.add_item('Réduire les points'){ decrease_point_size }
    menu.set_validation_proc(load_menu_id){ release_expired? ? MF_GRAYED : MF_ENABLED }
    menu.set_validation_proc(unload_menu_id){ release_expired? ? MF_GRAYED : MF_ENABLED }
    menu.set_validation_proc(plus_menu_id){ release_expired? ? MF_GRAYED : MF_ENABLED }
    menu.set_validation_proc(minus_menu_id){ release_expired? ? MF_GRAYED : MF_ENABLED }

    toolbar = UI::Toolbar.new('FGId Point Cloud')

    load_cmd = UI::Command.new('Ouvrir PCD') { choose_cloud }
    load_cmd.tooltip = 'Ouvrir PLY / LAS / LAZ / E57'
    load_cmd.small_icon = File.join(__dir__, 'icons', 'open_pcd.png')
    load_cmd.large_icon = File.join(__dir__, 'icons', 'open_pcd.png')
    load_cmd.status_bar_text = 'Ouvrir un nuage de points PLY, LAS, LAZ ou E57.'
    load_cmd.set_validation_proc { release_expired? ? MF_GRAYED : MF_ENABLED }
    toolbar.add_item(load_cmd)

    unload_cmd = UI::Command.new('Unload PCD') { unload_cloud }
    unload_cmd.tooltip = 'Décharger / fermer le PCD'
    unload_cmd.small_icon = File.join(__dir__, 'icons', 'unload_pcd.png')
    unload_cmd.large_icon = File.join(__dir__, 'icons', 'unload_pcd.png')
    unload_cmd.status_bar_text = 'Décharger le nuage et arrêter le renderer FGId.'
    unload_cmd.set_validation_proc { release_expired? ? MF_GRAYED : MF_ENABLED }
    toolbar.add_item(unload_cmd)

    plus_cmd = UI::Command.new('Points +') { increase_point_size }
    plus_cmd.tooltip = 'Grossir les points'
    plus_cmd.small_icon = File.join(__dir__, 'icons', 'point_plus.png')
    plus_cmd.large_icon = File.join(__dir__, 'icons', 'point_plus.png')
    plus_cmd.status_bar_text = 'Augmenter la taille des points.'
    plus_cmd.set_validation_proc { release_expired? ? MF_GRAYED : MF_ENABLED }
    toolbar.add_item(plus_cmd)

    minus_cmd = UI::Command.new('Points -') { decrease_point_size }
    minus_cmd.tooltip = 'Réduire les points'
    minus_cmd.small_icon = File.join(__dir__, 'icons', 'point_minus.png')
    minus_cmd.large_icon = File.join(__dir__, 'icons', 'point_minus.png')
    minus_cmd.status_bar_text = 'Réduire la taille des points.'
    minus_cmd.set_validation_proc { release_expired? ? MF_GRAYED : MF_ENABLED }
    toolbar.add_item(minus_cmd)

    toolbar.restore

    file_loaded(__FILE__)
  end
end
end
