# frozen_string_literal: true
# rbs_inline: enabled

module RbWasmVdom
  # Virtual DOM Node Definition
  class VNode
    attr_reader :tag      #: String
    attr_reader :props    #: Hash[String, String]
    attr_reader :children #: Array[VNode | String]

    # @rbs tag: String
    # @rbs props: Hash[String, String]
    # @rbs children: Array[VNode | String]
    # @rbs return: void
    def initialize(tag, props = {}, children = [])
      @tag = tag
      @props = props
      @children = children
    end
  end
end


module RbWasmVdom
  # Reactive State Management
  class ReactiveState
    # @rbs initial_data: Hash[Symbol, untyped]
    # @rbs &on_change: () -> void
    # @rbs return: void
    def initialize(initial_data, &on_change)
      @data = initial_data
      @on_change = on_change
    end

    # @rbs key: Symbol
    # @rbs return: untyped
    def [](key)
      @data[key]
    end

    # @rbs return: Array[Symbol]
    def keys
      @data.keys
    end

    # @rbs key: Symbol
    # @rbs value: untyped
    # @rbs return: void
    def []=(key, value)
      return if @data[key] == value

      @data[key] = value
      @on_change.call
    end
  end
end


module RbWasmVdom
  # HTML Template Parser
  class TemplateParser
    # @rbs return: void
    def self.setup_js_parser # rubocop:disable Metrics/MethodLength
      # Add a prefix to the function name to prevent global namespace pollution in JS
      JS.eval(<<~JS)
        window.__RbWasmVdom_parseHTMLToJSON = function(html) {
          const doc = new DOMParser().parseFromString(html, "text/html");
          function walk(node) {
            if (node.nodeType === 3) {
              const text = node.textContent.trim();
              return text ? text : null;
            }
            if (node.nodeType === 1) {
              const obj = { tag: node.tagName.toLowerCase(), props: {}, children: [] };
              for (let i = 0; i < node.attributes.length; i++) {
                obj.props[node.attributes[i].name] = node.attributes[i].value;
              }
              for (let i = 0; i < node.childNodes.length; i++) {
                const childRes = walk(node.childNodes[i]);
                if (childRes !== null) obj.children.push(childRes);
              }
              return obj;
            }
            return null;
          }
          const root = doc.body.firstElementChild;
          return root ? JSON.stringify(walk(root)) : "null";
        }
      JS
      @setup_done = true
    end

    # @rbs html_string: String
    # @rbs return: VNode | String | nil
    def self.parse(html_string)
      setup_js_parser unless @setup_done
      json_str = JS.global.__RbWasmVdom_parseHTMLToJSON(html_string).to_s
      return nil if json_str == "null" || json_str.empty?

      build_ast(JSON.parse(json_str))
    end

    # @rbs data: Hash[String, untyped] | String
    # @rbs return: VNode | String
    def self.build_ast(data)
      return data if data.is_a?(String)

      children = data["children"].map { |child| build_ast(child) }
      VNode.new(data["tag"], data["props"], children)
    end
  end
end


module RbWasmVdom
  module DomRenderer
    private

    # @rbs vnode: VNode | String
    # @rbs return: untyped
    def create_element(vnode)
      document = JS.global[:document]
      return document.createTextNode(vnode) if vnode.is_a?(String)

      el = document.createElement(vnode.tag)
      update_props(el, {}, vnode.props)

      vnode.children.each do |child|
        el.appendChild(create_element(child))
      end
      el
    end

    # @rbs el: untyped
    # @rbs old_props: Hash[String, String]
    # @rbs new_props: Hash[String, String]
    # @rbs return: void
    def update_props(el, old_props, new_props)
      remove_old_props(el, old_props, new_props)
      apply_new_props(el, old_props, new_props)
    end

    # @rbs el: untyped
    # @rbs old_props: Hash[String, String]
    # @rbs new_props: Hash[String, String]
    # @rbs return: void
    def remove_old_props(el, old_props, new_props)
      old_props.each_key do |key|
        el.removeAttribute(key) unless new_props.key?(key) || key.start_with?("@")
      end
    end

    # @rbs el: untyped
    # @rbs old_props: Hash[String, String]
    # @rbs new_props: Hash[String, String]
    # @rbs return: void
    def apply_new_props(el, old_props, new_props)
      new_props.each do |key, value|
        next if old_props[key] == value

        apply_prop(el, old_props, key, value)
      end
    end

    # @rbs el: untyped
    # @rbs old_props: Hash[String, String]
    # @rbs key: String
    # @rbs value: String
    # @rbs return: void
    def apply_prop(el, old_props, key, value)
      if key.start_with?("@")
        add_event_listener(el, old_props, key, value)
      elsif key == "value"
        update_value_prop(el, key, value)
      else
        el.setAttribute(key, value)
      end
    end

    # @rbs el: untyped
    # @rbs old_props: Hash[String, String]
    # @rbs key: String
    # @rbs value: String
    # @rbs return: void
    def add_event_listener(el, old_props, key, value)
      return if old_props.key?(key)

      event_name = key.sub(/^@/, "")
      el.addEventListener(event_name) do |e|
        @methods[value.to_sym].call(e, @state)
      end
    end

    # @rbs el: untyped
    # @rbs key: String
    # @rbs value: String
    # @rbs return: void
    def update_value_prop(el, key, value)
      el[:value] = value
      el.setAttribute(key, value)
    end
  end
end


module RbWasmVdom
  module Patcher
    include DomRenderer

    # @rbs parent_el: untyped
    # @rbs old_vnode: VNode | String | nil
    # @rbs new_vnode: VNode | String | nil
    # @rbs index: Integer
    # @rbs return: void
    def patch(parent_el, old_vnode, new_vnode, index)
      current_el = child_at(parent_el, index)

      return append_node(parent_el, new_vnode) if old_vnode.nil?
      return remove_node(parent_el, current_el) if new_vnode.nil?
      return replace_node(parent_el, current_el, new_vnode) if changed?(old_vnode, new_vnode)

      patch_element(current_el, old_vnode, new_vnode) if old_vnode.is_a?(VNode) && new_vnode.is_a?(VNode)
    end

    private

    # @rbs node1: VNode | String
    # @rbs node2: VNode | String
    # @rbs return: bool
    def changed?(node1, node2)
      return true if node1.class != node2.class
      return node1 != node2 if node1.is_a?(String)

      # @type var node1: VNode
      # @type var node2: VNode

      node1.tag != node2.tag
    end

    # @rbs parent_el: untyped
    # @rbs index: Integer
    # @rbs return: untyped
    def child_at(parent_el, index)
      parent_el[:childNodes].item(index)
    end

    # @rbs parent_el: untyped
    # @rbs new_vnode: VNode | String | nil
    # @rbs return: void
    def append_node(parent_el, new_vnode)
      parent_el.appendChild(create_element(new_vnode)) if new_vnode
    end

    # @rbs parent_el: untyped
    # @rbs current_el: untyped
    # @rbs return: void
    def remove_node(parent_el, current_el)
      parent_el.removeChild(current_el) unless current_el == JS::Null
    end

    # @rbs parent_el: untyped
    # @rbs current_el: untyped
    # @rbs new_vnode: VNode | String
    # @rbs return: void
    def replace_node(parent_el, current_el, new_vnode)
      parent_el.replaceChild(create_element(new_vnode), current_el)
    end

    # @rbs current_el: untyped
    # @rbs old_vnode: VNode
    # @rbs new_vnode: VNode
    # @rbs return: void
    def patch_element(current_el, old_vnode, new_vnode)
      update_props(current_el, old_vnode.props, new_vnode.props)

      old_children = old_vnode.children
      new_children = new_vnode.children

      patch_children(current_el, old_children, new_children)
    end

    # @rbs current_el: untyped
    # @rbs old_children: Array[VNode | String]
    # @rbs new_children: Array[VNode | String]
    # @rbs return: void
    def patch_children(current_el, old_children, new_children)
      remove_extra_children(current_el, old_children, new_children)
      patch_common_children(current_el, old_children, new_children)

      return unless new_children.length > old_children.length

      append_missing_children(current_el, old_children, new_children)
    end

    # @rbs current_el: untyped
    # @rbs old_children: Array[VNode | String]
    # @rbs new_children: Array[VNode | String]
    # @rbs return: void
    def remove_extra_children(current_el, old_children, new_children)
      return unless old_children.length > new_children.length

      (old_children.length - 1).downto(new_children.length) do |i|
        child_to_remove = current_el[:childNodes].item(i)
        current_el.removeChild(child_to_remove) unless child_to_remove == JS::Null
      end
    end

    # @rbs current_el: untyped
    # @rbs old_children: Array[VNode | String]
    # @rbs new_children: Array[VNode | String]
    # @rbs return: void
    def patch_common_children(current_el, old_children, new_children)
      [old_children.length, new_children.length].min.times do |i|
        patch(current_el, old_children[i], new_children[i], i)
      end
    end

    # @rbs current_el: untyped
    # @rbs old_children: Array[VNode | String]
    # @rbs new_children: Array[VNode | String]
    # @rbs return: void
    def append_missing_children(current_el, old_children, new_children)
      (old_children.length...new_children.length).each do |i|
        current_el.appendChild(create_element(new_children[i]))
      end
    end
  end
end


module RbWasmVdom
  class Interpolator
    IDENTIFIER_PATTERN = /^[a-z_]\w*$/

    # @rbs state: ReactiveState
    def initialize(state)
      @state = state
    end

    # @rbs text: String
    # @rbs locals: Hash[Symbol, untyped]
    # @rbs return: String
    def call(text, locals = {})
      interpolate_text(text.to_s, locals)
    end

    private

    # @rbs text: String
    # @rbs locals: Hash[Symbol, untyped]
    # @rbs return: String
    def interpolate_text(text, locals)
      result = +""
      index = 0

      while index < text.length
        range = interpolation_range(text, index)
        return result << text[index..].to_s unless range

        result << text[index...range.begin].to_s
        result << interpolate_range(text, range, locals)

        index = range.end + 1
      end

      result
    end

    # @rbs text: String
    # @rbs index: Integer
    # @rbs return: Range[Integer]?
    def interpolation_range(text, index)
      start_index = text.index("{{", index)
      return nil unless start_index

      end_index = text.index("}}", start_index + 2)
      return nil unless end_index

      start_index..(end_index + 1)
    end

    # @rbs text: String
    # @rbs range: Range[Integer]
    # @rbs locals: Hash[Symbol, untyped]
    # @rbs return: String
    def interpolate_range(text, range, locals)
      placeholder = text[range].to_s
      expression = text[(range.begin + 2)...(range.end - 1)].to_s.strip

      interpolate_expression(expression, placeholder, locals)
    end

    # @rbs expression: String
    # @rbs placeholder: String
    # @rbs locals: Hash[Symbol, untyped]
    # @rbs return: String
    def interpolate_expression(expression, placeholder, locals)
      return placeholder if expression.empty?

      value = EvaluationContext.new(@state, locals).evaluate(expression)
      return placeholder if value.nil?

      value.to_s
    rescue Exception # rubocop:disable Lint/RescueException
      placeholder
    end

    class EvaluationContext
      # @rbs state: ReactiveState
      # @rbs locals: Hash[Symbol, untyped]
      # @rbs return: void
      def initialize(state, locals)
        @state = state
        @locals = locals
      end

      # @rbs expression: String
      # @rbs return: untyped
      def evaluate(expression)
        eval(evaluation_code(expression)) # rubocop:disable Security/Eval
      end

      # @rbs key: Symbol
      # @rbs return: untyped
      def fetch_value(key)
        return ValueProxy.wrap(@locals[key]) if @locals.key?(key)

        ValueProxy.wrap(@state[key])
      end

      private

      # @rbs expression: String
      # @rbs return: String
      def evaluation_code(expression)
        "#{local_variable_assignments}\n#{expression}"
      end

      # @rbs return: String
      def local_variable_assignments
        visible_keys.map do |key|
          "#{key} = fetch_value(:#{key})"
        end.join("\n")
      end

      # @rbs return: Array[Symbol]
      def visible_keys
        (@locals.keys + state_keys).uniq.select do |key|
          valid_identifier?(key)
        end
      end

      # @rbs return: Array[Symbol]
      def state_keys
        @state.respond_to?(:keys) ? @state.keys : []
      end

      # @rbs key: Symbol
      # @rbs return: bool
      def valid_identifier?(key)
        key.to_s.match?(IDENTIFIER_PATTERN)
      end
    end

    class ValueProxy
      # @rbs value: untyped
      # @rbs return: untyped
      def self.wrap(value)
        return value if value.nil? || value == true || value == false
        return value if value.is_a?(Numeric)
        return value if value.is_a?(String)

        new(value)
      end

      # @rbs value: untyped
      # @rbs return: void
      def initialize(value)
        @value = value
      end

      # @rbs key: Symbol | String | Integer
      # @rbs return: untyped
      def [](key)
        return self.class.wrap(@value[key]) if @value.respond_to?(:[])

        nil
      end

      # @rbs return: String
      def to_s
        @value.to_s
      end

      private

      # @rbs name: Symbol
      # @rbs args: Array[untyped]
      # @rbs return: untyped
      def method_missing(name, *)
        return self.class.wrap(@value[name]) if hash_key?(name)
        return self.class.wrap(@value[name.to_s]) if hash_key?(name.to_s)
        return self.class.wrap(@value.public_send(name, *)) if @value.respond_to?(name)

        super
      end

      # @rbs name: Symbol
      # @rbs include_private: bool
      # @rbs return: bool
      def respond_to_missing?(name, include_private = false)
        hash_key?(name) || hash_key?(name.to_s) || @value.respond_to?(name) || super
      end

      # @rbs key: Symbol | String
      # @rbs return: bool
      def hash_key?(key)
        @value.respond_to?(:key?) && @value.key?(key)
      end
    end
  end
end


module RbWasmVdom
  module EachRenderer
    private

    # @rbs ast_node: VNode | String
    # @rbs locals: Hash[Symbol, untyped]
    # @rbs return: Array[VNode | String]
    def build_vdom_nodes(ast_node, locals = {})
      return [@interpolator.call(ast_node, locals)] if ast_node.is_a?(String)

      each_expression = ast_node.props["#each"]
      return build_each_nodes(ast_node, each_expression, locals) if each_expression

      [build_single_vnode(ast_node, locals)]
    end

    # @rbs ast_node: VNode
    # @rbs locals: Hash[Symbol, untyped]
    # @rbs return: VNode
    def build_single_vnode(ast_node, locals)
      new_props = {} #: Hash[String, String]
      ast_node.props.each do |key, value|
        next if key == "#each"

        new_props[key] = @interpolator.call(value, locals)
      end

      new_children = build_child_nodes(ast_node.children, locals)
      VNode.new(ast_node.tag, new_props, new_children)
    end

    # @rbs children: Array[VNode | String]
    # @rbs locals: Hash[Symbol, untyped]
    # @rbs return: Array[VNode | String]
    def build_child_nodes(children, locals)
      new_children = [] #: Array[VNode | String]

      children.each do |child|
        build_vdom_nodes(child, locals).each do |new_child|
          new_children << new_child
        end
      end

      new_children
    end

    # @rbs ast_node: VNode
    # @rbs expression: String
    # @rbs locals: Hash[Symbol, untyped]
    # @rbs return: Array[VNode | String]
    def build_each_nodes(ast_node, expression, locals)
      parsed = parse_each_expression(expression)
      return [build_single_vnode(ast_node, locals)] unless parsed

      first_name, second_name, collection_name = parsed
      collection = @state[collection_name.to_sym]

      build_collection_nodes(ast_node, collection, first_name, second_name, locals)
    end

    # @rbs expression: String
    # @rbs return: [String, String?, String]?
    def parse_each_expression(expression)
      parse_each_single_value_expression(expression) ||
        parse_each_pair_expression(expression)
    end

    # @rbs expression: String
    # @rbs return: [String, nil, String]?
    def parse_each_single_value_expression(expression)
      match = expression.match(/^\s*(\w+)\s+in\s+(\w+)\s*$/)
      return nil unless match

      value_name = match[1]
      collection_name = match[2]
      return nil unless value_name && collection_name

      [value_name, nil, collection_name]
    end

    # @rbs expression: String
    # @rbs return: [String, String, String]?
    def parse_each_pair_expression(expression)
      match = expression.match(/^\s*(\w+)\s*,\s*(\w+)\s+in\s+(\w+)\s*$/)
      return nil unless match

      first_name = match[1]
      second_name = match[2]
      collection_name = match[3]
      return nil unless first_name && second_name && collection_name

      [first_name, second_name, collection_name]
    end

    # @rbs ast_node: VNode
    # @rbs collection: untyped
    # @rbs first_name: String
    # @rbs second_name: String?
    # @rbs locals: Hash[Symbol, untyped]
    # @rbs return: Array[VNode | String]
    def build_collection_nodes(ast_node, collection, first_name, second_name, locals)
      case collection
      when Array
        build_array_nodes(ast_node, collection, first_name, second_name, locals)
      when Hash
        build_hash_nodes(ast_node, collection, first_name, second_name, locals)
      else
        []
      end
    end

    # @rbs ast_node: VNode
    # @rbs collection: Array[untyped]
    # @rbs item_name: String
    # @rbs index_name: String?
    # @rbs locals: Hash[Symbol, untyped]
    # @rbs return: Array[VNode | String]
    def build_array_nodes(ast_node, collection, item_name, index_name, locals)
      nodes = [] #: Array[VNode | String]
      index = 0

      collection.each do |item|
        item_locals = locals.merge(item_name.to_sym => item)
        item_locals[index_name.to_sym] = index if index_name

        nodes << build_single_vnode(ast_node, item_locals)
        index += 1
      end

      nodes
    end

    # @rbs ast_node: VNode
    # @rbs collection: Hash[untyped, untyped]
    # @rbs key_name: String
    # @rbs value_name: String?
    # @rbs locals: Hash[Symbol, untyped]
    # @rbs return: Array[VNode | String]
    def build_hash_nodes(ast_node, collection, key_name, value_name, locals)
      nodes = [] #: Array[VNode | String]

      collection.each do |key, value|
        item_locals = locals.merge(key_name.to_sym => key)
        item_locals[value_name.to_sym] = value if value_name

        nodes << build_single_vnode(ast_node, item_locals)
      end

      nodes
    end
  end
end


module RbWasmVdom
  # Framework Core
  class App
    include Patcher
    include EachRenderer

    # @rbs selector: String
    # @rbs template: String
    # @rbs state: Hash[Symbol, untyped]
    # @rbs methods: Hash[Symbol, Proc]
    # @rbs return: void
    def initialize(selector, template:, state:, methods:)
      @el = JS.global[:document].querySelector(selector)
      @template_ast = TemplateParser.parse(template)
      @methods = methods
      @current_vnode = nil

      @state = ReactiveState.new(state) do
        render_cycle
      end

      @interpolator = Interpolator.new(@state)

      render_cycle
    end

    private

    # @rbs return: void
    def render_cycle
      new_vnode = build_vdom(@template_ast)

      if @current_vnode.nil?
        @el[:innerHTML] = ""
        @el.appendChild(create_element(new_vnode))
      else
        patch(@el, @current_vnode, new_vnode, 0)
      end

      @current_vnode = new_vnode
    end

    # @rbs ast_node: VNode | String
    # @rbs return: VNode | String
    def build_vdom(ast_node)
      build_vdom_nodes(ast_node).first
    end
  end
end


require "js"
require "json"

module RbWasmVdom
  # @rbs selector: String
  # @rbs template: String
  # @rbs state: Hash[Symbol, untyped]
  # @rbs methods: Hash[Symbol, Proc]
  # @rbs return: App
  def self.create_app(selector, template: "", state: {}, methods: {})
    App.new(selector, template: template, state: state, methods: methods)
  end
end
