# frozen_string_literal: true

require 'open3'
require 'json'
require 'fileutils'

module UITest
  # Wrapper for AXe CLI tool for iOS Simulator automation
  class Axe
    class AxeError < StandardError; end

    attr_reader :udid

    def initialize(udid)
      @udid = udid
    end

    # Get the full accessibility UI tree as parsed JSON
    # @return [Hash] Parsed accessibility tree
    def describe_ui
      output, status = run_axe('describe-ui')
      raise AxeError, "describe-ui failed: #{output}" unless status.success?

      JSON.parse(output)
    rescue JSON::ParserError => e
      raise AxeError, "Failed to parse accessibility tree: #{e.message}"
    end

    # Check if an element with the given accessibility identifier exists
    # @param identifier [String] Accessibility identifier to find
    # @return [Boolean] true if element exists
    def element_exists?(identifier)
      tree = describe_ui
      find_element_in_tree(tree, identifier: identifier).present?
    rescue AxeError
      false
    end

    # Find an element by accessibility identifier
    # @param identifier [String] Accessibility identifier
    # @return [Hash, nil] Element data or nil if not found
    def find_element(identifier)
      tree = describe_ui
      find_element_in_tree(tree, identifier: identifier)
    end

    # Check if text is visible anywhere in the accessibility tree
    # @param text [String] Text to search for
    # @return [Boolean] true if text is visible
    def text_visible?(text)
      tree = describe_ui
      find_element_in_tree(tree, label: text).present?
    rescue AxeError
      false
    end

    # Whether any element in the given tree has an AXUniqueId starting with the
    # given prefix. Pass an already-fetched tree to avoid re-spawning `axe`.
    def self.id_with_prefix_in_tree?(node, prefix)
      case node
      when Hash
        return true if node['AXUniqueId']&.start_with?(prefix)

        node.each_value do |value|
          return true if id_with_prefix_in_tree?(value, prefix)
        end
      when Array
        node.each do |item|
          return true if id_with_prefix_in_tree?(item, prefix)
        end
      end
      false
    end

    # Whether any element in the given tree has an AXLabel containing `text`.
    # Pass an already-fetched tree to avoid re-spawning `axe`.
    def self.label_in_tree?(node, text)
      case node
      when Hash
        return true if node['AXLabel']&.include?(text)

        node.each_value do |value|
          return true if label_in_tree?(value, text)
        end
      when Array
        node.each do |item|
          return true if label_in_tree?(item, text)
        end
      end
      false
    end

    # Whether any element in the given tree has the given AXUniqueId. Pass an
    # already-fetched tree to avoid re-spawning `axe`.
    def self.id_in_tree?(node, identifier)
      case node
      when Hash
        return true if node['AXUniqueId'] == identifier

        node.each_value do |value|
          return true if id_in_tree?(value, identifier)
        end
      when Array
        node.each do |item|
          return true if id_in_tree?(item, identifier)
        end
      end
      false
    end

    # Tap on an element by accessibility identifier
    # @param identifier [String] Accessibility identifier
    def tap_id(identifier)
      output, status = run_axe('tap', '--id', identifier)
      raise AxeError, "tap failed: #{output}" unless status.success?
    end

    # Tap on an element by accessibility label
    # @param label [String] Accessibility label
    def tap_label(label)
      output, status = run_axe('tap', '--label', label)
      raise AxeError, "tap failed: #{output}" unless status.success?
    end

    # Tap at specific coordinates
    # @param x [Integer] X coordinate
    # @param y [Integer] Y coordinate
    def tap_coordinates(x:, y:)
      output, status = run_axe('tap', '-x', x.to_s, '-y', y.to_s)
      raise AxeError, "tap failed: #{output}" unless status.success?
    end

    # Perform a swipe gesture
    # @param start_x [Integer] Starting X coordinate
    # @param start_y [Integer] Starting Y coordinate
    # @param end_x [Integer] Ending X coordinate
    # @param end_y [Integer] Ending Y coordinate
    # @param duration [Float] Duration in seconds (optional)
    def swipe(start_x:, start_y:, end_x:, end_y:, duration: nil)
      args = ['swipe', '--start-x', start_x.to_s, '--start-y', start_y.to_s,
              '--end-x', end_x.to_s, '--end-y', end_y.to_s]
      args += ['--duration', duration.to_s] if duration

      output, status = run_axe(*args)
      raise AxeError, "swipe failed: #{output}" unless status.success?
    end

    # Perform a preset gesture
    # @param preset [String] Gesture preset (scroll-up, scroll-down, etc.)
    def gesture(preset)
      output, status = run_axe('gesture', preset)
      raise AxeError, "gesture failed: #{output}" unless status.success?
    end

    # Type text
    # @param text [String] Text to type
    def type(text)
      output, status = Open3.capture2e(self.class.axe_env, 'axe', 'type', '--stdin', '--udid', @udid, stdin_data: text)
      output = output.dup.force_encoding('UTF-8') if output.is_a?(String)
      raise AxeError, "type failed: #{output}" unless status.success?
    end

    # Press the home button
    def home_button
      output, status = run_axe('button', 'home')
      raise AxeError, "home button failed: #{output}" unless status.success?
    end

    # Press a key by keycode
    # @param keycode [Integer] HID keycode (e.g., 40 for Return/Enter)
    def press_key(keycode)
      output, status = run_axe('key', keycode.to_s)
      raise AxeError, "key press failed: #{output}" unless status.success?
    end

    # Press Return/Enter key
    def press_return
      press_key(40)
    end

    # Press Escape key
    def press_escape
      press_key(41)
    end

    # Take a screenshot and save it
    # @param name [String] Screenshot name (without extension)
    # @return [String] Path to the saved screenshot
    def screenshot(name)
      Config.ensure_directories!

      path = File.join(Config.current_dir, "#{name}.png")
      output, status = run_axe('screenshot', '--output', path)
      raise AxeError, "screenshot failed: #{output}" unless status.success?

      # Wait for file to be fully written to disk
      wait_for_file(path)

      path
    end

    private

    def run_axe(*)
      output, status = Open3.capture2e(self.class.axe_env, 'axe', *, '--udid', @udid)
      output = output.dup.force_encoding('UTF-8') if output.is_a?(String)
      [output, status]
    end

    class << self
      # Environment overrides for every `axe` invocation.
      #
      # AXe loads Xcode's private SimulatorKit.framework at runtime. Some Xcode
      # betas ship without it (e.g. Xcode 27 beta), which makes every `axe`
      # command fail with "Failed to load essential private frameworks". When
      # the active Xcode is missing SimulatorKit, fall back to any installed
      # Xcode that has it by overriding DEVELOPER_DIR for the axe subprocess.
      # @return [Hash] Environment hash to pass to Open3
      def axe_env
        return @axe_env if defined?(@axe_env)

        @axe_env = simulatorkit?(active_developer_dir) ? {} : fallback_env
      end

      private

      def fallback_env
        dir = usable_developer_dir
        dir ? { 'DEVELOPER_DIR' => dir } : {}
      end

      # Path to the active Xcode's Developer directory (xcode-select -p)
      def active_developer_dir
        @active_developer_dir ||= `xcode-select -p 2>/dev/null`.strip
      end

      # First installed Xcode Developer directory that ships SimulatorKit
      def usable_developer_dir
        Dir.glob('/Applications/Xcode*.app/Contents/Developer').find do |dir|
          simulatorkit?(dir)
        end
      end

      # Whether a Developer directory ships the SimulatorKit private framework
      def simulatorkit?(developer_dir)
        return false if developer_dir.nil? || developer_dir.empty?

        File.exist?(File.join(developer_dir, 'Library', 'PrivateFrameworks', 'SimulatorKit.framework'))
      end
    end

    # Wait for a file to exist and have non-zero size
    # Helps avoid race conditions where screenshot isn't fully written
    # @param path [String] Path to the file
    # @param timeout [Float] Maximum time to wait in seconds
    def wait_for_file(path, timeout: 2.0)
      start_time = Time.now
      loop do
        return if File.exist?(path) && File.size(path) > 100

        break if Time.now - start_time > timeout

        sleep 0.1
      end
    end

    # Recursively search the accessibility tree for an element
    # @param node [Hash, Array] Current node in the tree
    # @param identifier [String, nil] Accessibility identifier to match (AXUniqueId)
    # @param label [String, nil] Accessibility label to match (AXLabel)
    # @return [Hash, nil] Found element or nil
    def find_element_in_tree(node, identifier: nil, label: nil)
      case node
      when Hash
        # Check if this node matches by identifier (AXUniqueId in AXe output)
        return node if identifier && node['AXUniqueId'] == identifier

        # Check if this node matches by label (AXLabel in AXe output)
        return node if label && node['AXLabel']&.include?(label)

        # Recursively search children
        node.each_value do |value|
          result = find_element_in_tree(value, identifier: identifier, label: label)
          return result if result
        end
      when Array
        node.each do |item|
          result = find_element_in_tree(item, identifier: identifier, label: label)
          return result if result
        end
      end

      nil
    end
  end
end

# Add present? method for nil/empty checking
class Object
  def present?
    respond_to?(:empty?) ? !empty? : !nil?
  end
end

class NilClass
  def present?
    false
  end
end
