# ---------------------------------------------------------------------------
# App
# ---------------------------------------------------------------------------
class App
  DEBUG_LOG       = true
  TX_CHUNK_SIZE   = 32
  TX_CHUNK_GAP_MS = 20

  attr_accessor :current_port

  def initialize
    @doc = JS.document
    @current_port = nil
    @auto_reconnect = false
    setup_terminal
    unless JS::WebSerial.supported?
      el('not-supported').style.display = 'block'
      el('connect-btn')[:disabled] = true
    end
    bind_events
    append_log("[*] Ready. Connect a serial port to use the file editor.")
  end

  def el(id)
    @doc.getElementById(id)
  end

  def setup_terminal
    opts = JS.global.create_object
    opts[:scrollback] = 1000
    opts[:cursorBlink] = true
    opts[:convertEol] = true
    opts[:fontFamily] = '"Courier Prime", Courier, monospace'
    opts[:fontSize] = 15

    theme = JS.global.create_object
    theme[:background] = '#000000'
    opts[:theme] = theme

    terminal = JS.global[:Terminal].new(opts)
    @fit_addon = JS.global[:FitAddon][:FitAddon].new
    terminal.loadAddon(@fit_addon)
    container = el('terminal-container')
    terminal.method_missing(:open, container)
    fit_terminal

    resize_observer = JS.global[:ResizeObserver].new do |_entries|
      fit_terminal
    end
    resize_observer.observe(container)

    # Ensure Escape key is captured by xterm instead of the browser
    terminal.attachCustomKeyEventHandler do |event|
      if event[:key].to_s == 'Escape'
        event.preventDefault
      end
      true
    end

    @terminal = terminal
  end

  def update_status(text, connected)
    status = el('status')
    status[:textContent] = text
    status[:className] = connected ? 'connected' : 'disconnected'
  end

  def fit_terminal
    @fit_addon&.fit
  end

  def set_editor_font(val)
    val = [[8, val].max, 24].min
    el('editor-font-size')[:value] = val

    font_size = "#{val}px"
    el('editor').style.fontSize = font_size
    # Match the textarea font size so the highlight display stays aligned.
    el('highlight-content').style.fontSize = font_size
  end

  def set_term_font(val)
    val = [[8, val].max, 32].min
    el('term-font-size')[:value] = val
    @terminal[:options][:fontSize] = val
    fit_terminal
  end

  def compile_mode?
    el('mode-compile')[:checked] == true
  end

  def force_mrb_extension
    path_el = el('path-input')
    path = path_el[:value].to_s.strip
    return if path.empty?
    return if path.end_with?('.mrb')
    dot_pos = path.rindex('.')
    if dot_pos
      new_path = path[0, dot_pos] + '.mrb'
      path_el[:value] = new_path
      append_log("[*] Extension changed: #{path} -> #{new_path}")
    else
      new_path = path + '.mrb'
      path_el[:value] = new_path
      append_log("[*] Extension added: #{path} -> #{new_path}")
    end
  end

  def update_dfu_buttons
    has_path    = !el('path-input')[:value].to_s.strip.empty?
    has_content = !el('editor')[:value].to_s.strip.empty?
    set_dfu_buttons_enabled(connected? && has_path, has_content)
  end

  def set_dfu_buttons_enabled(enabled, has_content = true)
    if enabled && has_content
      el('upload-btn').removeAttribute('disabled')
    else
      el('upload-btn').setAttribute('disabled', 'true')
    end
    if enabled && !compile_mode?
      el('download-btn').removeAttribute('disabled')
    else
      el('download-btn').setAttribute('disabled', 'true')
    end
  end


  def connected?
    !@current_port.nil?
  end

  def auto_reconnect?
    @auto_reconnect == true
  end

  def auto_reconnect=(enabled)
    @auto_reconnect = enabled == true
  end

  def error_text(err)
    msg = err.message.to_s
    msg = err.to_s if msg.empty?
    klass = err.class.to_s
    msg.empty? ? klass : "#{klass}: #{msg}"
  end

  def send_text_to_port(str, port = nil)
    port ||= current_port
    return unless port
    port.write(str)
  end

  def connect
    disconnect if current_port
    begin
      baud_rate = el('baud-rate')[:value].to_i
      @terminal.reset
      JS::WebSerial.connect(baud_rate: baud_rate) do |ws|
        self.current_port = ws
        ws.start_terminal_read(@terminal)
        self.auto_reconnect = true
        update_status('Connected', true)
        update_dfu_buttons
        el('connect-btn')[:textContent] = 'Disconnect'
        @terminal.focus
      end
    rescue => err
      txt = error_text(err)
      JS.global[:console].error("Connect error: #{txt}")
      append_log("[-] Connect error: #{txt}")
      update_status('Disconnected', false)
      self.current_port = nil
      update_dfu_buttons
    end
  end

  def disconnect(manual: false)
    return if @disconnecting

    @disconnecting = true
    self.auto_reconnect = false if manual

    port = current_port
    port&.close rescue nil
    self.current_port = nil

    update_status('Disconnected', false)
    update_dfu_buttons
    el('connect-btn')[:textContent] = 'Connect'
  ensure
    @disconnecting = false
  end

  def append_log(msg)
    log = el('dfu-log')
    log[:textContent] = log[:textContent].to_s + msg + "\n"
    log[:scrollTop] = log[:scrollHeight]
  end

  def debug_log(msg)
    return unless DEBUG_LOG
    t = Time.now.to_f
    append_log("[DBG #{t}] #{msg}")
  end

  def current_path
    p = el('path-input')[:value].to_s.strip
    p.empty? ? nil : p
  end

  def bind_events
    @terminal.onData do |data|
      send_text_to_port(data.to_s)
    end
    el('connect-btn').addEventListener('click') { connected? ? disconnect(manual: true) : connect }
    el('upload-btn').addEventListener('click') { do_upload }
    el('download-btn').addEventListener('click') { do_download }
    JS.global.addEventListener('serial-reader-closed') { disconnect }
    bind_font_controls
    bind_resizer
    bind_file_selector
    el('path-input').addEventListener('input') { update_dfu_buttons }
    el('editor').addEventListener('input') { update_dfu_buttons }

    # Mode radio buttons
    el('mode-plain').addEventListener('change') { el('download-btn').removeAttribute('disabled') }
    el('mode-compile').addEventListener('change') do
      el('download-btn').setAttribute('disabled', 'true')
      force_mrb_extension
    end

    # Force .mrb extension on blur when compile mode
    el('path-input').addEventListener('blur') do
      next unless compile_mode?
      force_mrb_extension
    end

    if JS::WebSerial.supported?
      serial = JS.global[:navigator][:serial]
      serial.addEventListener('connect') do |e|
        next if current_port || !auto_reconnect?
        raw_port = e[:target] || e[:port]
        next unless raw_port
        update_status('Reconnecting...', false)
        sleep_ms 1500
        begin
          ws = JS::WebSerial.new(raw_port)
          baud_rate = el('baud-rate')[:value].to_i
          ws.open(baud_rate: baud_rate)
          ws.start_terminal_read(@terminal)
          self.current_port = ws
          update_status('Connected', true)
          update_dfu_buttons
          @terminal.writeln('[Reconnected]')
        rescue => err
          JS.global[:console].error("Auto-reconnect failed: #{err}")
          update_status('Disconnected', false)
        end
      end

      serial.addEventListener('disconnect') do
        next unless current_port
        @terminal.writeln("\r\n[Serial port disconnected]")
        disconnect
      end
    end
  end

  def bind_font_controls
    term_font_input = el('term-font-size')
    el('term-font-minus').addEventListener('click') { set_term_font(term_font_input[:value].to_i - 1) }
    el('term-font-plus').addEventListener('click')  { set_term_font(term_font_input[:value].to_i + 1) }
    term_font_input.addEventListener('change') { set_term_font(term_font_input[:value].to_i) }

    editor_font_input = el('editor-font-size')
    el('editor-font-minus').addEventListener('click') { set_editor_font(editor_font_input[:value].to_i - 1) }
    el('editor-font-plus').addEventListener('click')  { set_editor_font(editor_font_input[:value].to_i + 1) }
    editor_font_input.addEventListener('change') { set_editor_font(editor_font_input[:value].to_i) }
  end

  def bind_resizer
    resizer           = el('resizer')
    editor_container  = el('editor-container')
    editor_wrapper_el = el('editor-wrapper')
    dfu_log_el        = el('dfu-log')
    body              = @doc.body
    is_resizing       = false

    resizer.addEventListener('mousedown') do
      is_resizing = true
      body.style.userSelect = 'none'
      body.style.cursor = 'col-resize'
    end

    @doc.addEventListener('mousemove') do |e|
      next unless is_resizing
      rect                         = editor_container.getBoundingClientRect()
      new_left_x                   = e.clientX.to_f - rect.left.to_f
      max_x                        = rect.width.to_f - 20.0
      constrained                  = [100.0, [new_left_x, max_x].min].max
      left_ratio                   = constrained / rect.width.to_f
      right_ratio                  = 1.0 - left_ratio
      editor_wrapper_el.style.flex = left_ratio.to_s
      dfu_log_el.style.flex        = right_ratio.to_s
    end

    @doc.addEventListener('mouseup') do
      if is_resizing
        is_resizing = false
        body.style.userSelect = ''
        body.style.cursor = ''
      end
    end
  end

  def bind_file_selector
    el('file-input').addEventListener('change') do
      file = el('file-input')[:files][0]
      next unless file
      reader = JS.global[:FileReader].new
      reader.addEventListener('load') do |e|
        el('editor')[:value] = e[:target][:result].to_s
        # Dispatch an input event so EditorEventHandler can handle the update.
        el('editor').dispatchEvent(JS.global[:Event].new('input'))
        el('local-file-path')[:textContent] = file[:name].to_s
        el('file-input')[:value] = ''
        update_dfu_buttons
      end
      reader.readAsText(file)
    end
  end

  # -------------------------------------------------------------------
  # PicoModem protocol helpers
  # -------------------------------------------------------------------

  # Access the underlying JS port object for binary capture API
  def js_port
    current_port.instance_variable_get(:@js_port)
  end

  # Send raw bytes in small chunks for USB-CDC stability
  def picomodem_send_bytes(data)
    port = current_port
    return unless port
    off = 0
    while off < data.bytesize
      remain = data.bytesize - off
      size = TX_CHUNK_SIZE
      size = remain if remain < size
      chunk = data.byteslice(off, size)
      port.write(chunk)
      off += size
      sleep_ms TX_CHUNK_GAP_MS if off < data.bytesize
    end
    port.drain.await
  end

  # Build an PicoModem frame: STX(1) + Length(2) + Cmd(1) + Payload(N) + CRC16(2)
  def picomodem_build_frame(cmd, payload = "")
    body = cmd.chr + payload.to_s
    crc = CRC.crc16(body)
    [0x02, body.bytesize].pack("Cn") + body + [crc].pack("n")
  end

  # Send one PicoModem frame
  def picomodem_send_frame(cmd, payload = "")
    picomodem_send_bytes(picomodem_build_frame(cmd, payload))
  end

  # Read exactly n bytes from binary capture with timeout
  def picomodem_read_exact(n, timeout_ms)
    buf = ""
    waited = 0
    while buf.bytesize < n
      chunk = JS::WebSerial.binary_capture_read(js_port, n - buf.bytesize)
      if chunk && 0 < chunk.bytesize
        buf << chunk
        waited = 0
      else
        if timeout_ms <= waited
          debug_log("PicoModem:read_exact timeout need=#{n} got=#{buf.bytesize}")
          return nil
        end
        sleep_ms 10
        waited += 10
      end
    end
    buf
  end

  # Receive one PicoModem frame. Returns [cmd, payload] or nil on timeout/error.
  # Skips any non-STX bytes (residual terminal output) until a valid
  # STX frame start is found.
  def picomodem_recv_frame(timeout_ms = PicoModem::TIMEOUT_MS)
    # Scan for STX, skipping residual terminal output
    waited = 0
    while true
      b = JS::WebSerial.binary_capture_read(js_port, 1)
      if b && 0 < b.bytesize
        if b.getbyte(0) == 0x02
          break
        end
        # Skip non-STX byte
        next
      end
      if timeout_ms <= waited
        debug_log("PicoModem:recv timeout waiting for STX")
        return nil
      end
      sleep_ms 10
      waited += 10
    end
    # Read length (2 bytes big-endian)
    len_bytes = picomodem_read_exact(2, timeout_ms)
    return nil unless len_bytes
    length = len_bytes.unpack("n")[0]
    # Read body + CRC16
    rest = picomodem_read_exact(length + 2, timeout_ms)
    return nil unless rest
    body = rest.byteslice(0, length)
    expected_crc = rest.byteslice(length, 2).unpack("n")[0]
    actual_crc = CRC.crc16(body)
    unless actual_crc == expected_crc
      debug_log("PicoModem:crc16 mismatch expected=#{expected_crc} actual=#{actual_crc}")
      return nil
    end
    cmd = body.getbyte(0)
    payload = 1 < length ? body.byteslice(1, length - 1) : ""
    [cmd, payload]
  end

  # Enter PicoModem mode: send STX, wait for ACK, then start binary capture.
  # The device prints "\n^B" (displayed on terminal) then sends ACK (0x06).
  # Text capture detects ACK while terminal.write renders the ^B normally.
  def picomodem_enter_mode
    jsp = js_port
    JS::WebSerial.capture_start(jsp)
    current_port.write("\x02")
    current_port.drain.await
    # Wait for ACK (0x06) in text capture
    waited = 0
    while waited < PicoModem::TIMEOUT_MS
      captured = JS::WebSerial.capture_peek(jsp).to_s
      if captured.include?("\x06")
        break
      end
      sleep_ms 1
      waited += 1
    end
    JS::WebSerial.capture_stop(jsp)
    # Now start binary capture for PicoModem frames
    JS::WebSerial.binary_capture_start(jsp)
  end

  # Exit PicoModem mode: optionally send ABORT, stop binary capture
  def picomodem_exit_mode(send_abort = false)
    if send_abort
      begin
        picomodem_send_frame(PicoModem::ABORT)
        sleep_ms 100
      rescue
        # best effort
      end
    end
    JS::WebSerial.binary_capture_stop(js_port) rescue nil
  end

  # -------------------------------------------------------------------
  # File download via PicoModem
  # -------------------------------------------------------------------
  def do_download
    return unless connected?
    path = current_path
    return unless path
    set_dfu_buttons_enabled(false)
    append_log("[*] Download: #{path}")

    success = false
    begin
      picomodem_enter_mode

      picomodem_send_frame(PicoModem::FILE_READ, path)
      debug_log("PicoModem:file_read sent path=#{path}")

      data = ""
      total = nil
      while true
        frame = picomodem_recv_frame
        unless frame
          append_log("[-] Timeout waiting for response")
          return
        end

        cmd, payload = frame
        case cmd
        when PicoModem::FILE_DATA
          if total.nil?
            # First chunk: 4-byte big-endian total size + data
            if payload.bytesize < 4
              append_log("[-] Invalid FILE_DATA: missing size header")
              return
            end
            total = payload.byteslice(0, 4).unpack("N")[0]
            chunk = payload.byteslice(4, payload.bytesize - 4)
            append_log("[.] File size: #{total} bytes")
          else
            chunk = payload
          end
          data << chunk if chunk && 0 < chunk.bytesize
          debug_log("PicoModem:chunk recv=#{chunk&.bytesize || 0} progress=#{data.bytesize}/#{total}")
          picomodem_send_frame(PicoModem::CHUNK_ACK, PicoModem::OK.chr)
        when PicoModem::DONE_ACK
          if 5 <= payload.bytesize
            remote_crc = payload.byteslice(1, 4).unpack("N")[0]
            local_crc = CRC.crc32(data)
            if local_crc == remote_crc
              el('editor')[:value] = data
              el('editor').dispatchEvent(JS.global[:Event].new('input'))
              append_log("[+] Downloaded #{data.bytesize} bytes (CRC32 verified)")
              success = true
            else
              append_log("[-] CRC32 mismatch: local=0x#{local_crc.to_s(16)} remote=0x#{remote_crc.to_s(16)}")
            end
          else
            el('editor')[:value] = data
            el('editor').dispatchEvent(JS.global[:Event].new('input'))
            append_log("[+] Downloaded #{data.bytesize} bytes")
            success = true
          end
          return
        when PicoModem::ERROR
          append_log("[-] Device error: #{payload}")
          return
        else
          append_log("[-] Unexpected cmd=0x#{cmd.to_s(16)}")
          return
        end
      end
    rescue => err
      append_log("[-] Download error: #{error_text(err)}")
    ensure
      picomodem_exit_mode(!success)
      update_dfu_buttons
    end
  end

  # -------------------------------------------------------------------
  # File upload via PicoModem
  # -------------------------------------------------------------------
  def do_upload
    if @picomodem_uploading
      append_log("[-] Upload already running")
      return
    end
    @picomodem_uploading = true
    set_dfu_buttons_enabled(false)

    unless connected?
      append_log("[-] Not connected")
      return
    end

    code = el('editor')[:value].to_s
    if code.strip.empty?
      append_log("[-] Editor is empty")
      return
    end

    path = current_path
    unless path
      append_log("[-] No path specified")
      return
    end

    # Compile to mrb if compile mode is selected
    if compile_mode?
      append_log("[*] Compiling Ruby to mrb...")
      begin
        code = PicoRubyVM::InstructionSequence.compile(code).to_binary
      rescue => e
        append_log("[-] Compile error: #{e.message}")
        return
      end
      append_log("[+] Compiled: #{code.bytesize} bytes")
    end

    append_log("[*] Upload: #{path} (#{code.bytesize} bytes)")
    success = false
    begin
      picomodem_enter_mode

      # FILE_WRITE payload: 4-byte big-endian size + path
      picomodem_send_frame(PicoModem::FILE_WRITE, [code.bytesize].pack("N") + path)
      debug_log("PicoModem:file_write sent path=#{path} size=#{code.bytesize}")

      # Wait for FILE_ACK READY
      frame = picomodem_recv_frame
      unless frame
        append_log("[-] Timeout waiting for READY")
        return
      end
      if frame[0] == PicoModem::ERROR
        append_log("[-] Device error: #{frame[1]}")
        return
      end
      unless frame[0] == PicoModem::FILE_ACK
        append_log("[-] Unexpected response: cmd=0x#{frame[0].to_s(16)}")
        return
      end
      append_log("[.] Device ready, sending data...")

      # Send chunks
      offset = 0
      while offset < code.bytesize
        remain = code.bytesize - offset
        size = PicoModem::CHUNK_SIZE
        size = remain if remain < size
        chunk = code.byteslice(offset, size)
        picomodem_send_frame(PicoModem::CHUNK, chunk)

        ack = picomodem_recv_frame
        unless ack
          append_log("[-] Timeout waiting for ACK at offset #{offset}")
          return
        end
        if ack[0] == PicoModem::ERROR
          append_log("[-] Device error: #{ack[1]}")
          return
        end
        unless ack[0] == PicoModem::CHUNK_ACK
          append_log("[-] Unexpected response: cmd=0x#{ack[0].to_s(16)}")
          return
        end
        offset += size
        debug_log("PicoModem:chunk_ack progress=#{offset}/#{code.bytesize}")
      end

      # Wait for DONE_ACK (device writes file then responds)
      frame = picomodem_recv_frame
      unless frame
        append_log("[-] Timeout waiting for completion")
        return
      end

      case frame[0]
      when PicoModem::DONE_ACK
        payload = frame[1]
        if 5 <= payload.bytesize
          status, remote_crc = payload.byteslice(0, 5).unpack("CN")
          local_crc = CRC.crc32(code)
          if status == PicoModem::OK && local_crc == remote_crc
            append_log("[+] Upload complete (CRC32 verified)")
            success = true
          elsif status == PicoModem::OK
            append_log("[-] CRC32 mismatch: local=0x#{local_crc.to_s(16)} remote=0x#{remote_crc.to_s(16)}")
          else
            append_log("[-] Upload failed: status=0x#{status.to_s(16)}")
          end
        else
          append_log("[+] Upload complete")
          success = true
        end
      when PicoModem::ERROR
        append_log("[-] Device error: #{frame[1]}")
      else
        append_log("[-] Unexpected response: cmd=0x#{frame[0].to_s(16)}")
      end
    rescue => err
      append_log("[-] Upload error: #{error_text(err)}")
    ensure
      @picomodem_uploading = false
      picomodem_exit_mode(!success)
      update_dfu_buttons
    end
  end
end

JS.global[:__ruby_description] = RUBY_DESCRIPTION
App.new
