# frozen_string_literal: true

require 'uri'

module UITest
  # Helper for setting up instances in UI tests.
  # Provides methods to navigate through Settings and add/verify instances.
  class InstanceSetup
    # Coordinates for iPhone 17 Pro (393pt width)
    # Settings gear button in Home toolbar (top-right)
    SETTINGS_BUTTON_COORDS = { x: 380, y: 70 }.freeze

    def initialize(axe)
      @axe = axe
    end

    # Check if Yattee Server instance exists by navigating to Sources
    # @param host [String] Host portion of the server URL
    # @return [Boolean] true if instance exists
    def yattee_server_exists?(host)
      navigate_to_sources
      exists = @axe.text_visible?(host)
      close_settings
      exists
    end

    # Add a Yattee Server instance via Detect & Add flow
    # @param url [String] Full URL of the Yattee Server
    def add_yattee_server(url)
      navigate_to_sources

      # Tap Add Source button (toolbar or empty state)
      tap_add_source_button
      sleep 0.8

      # Select Remote Server from the source type list
      select_remote_server_tab

      # Wait for URL field to appear
      wait_for_element('addRemoteServer.urlField')

      # Enter URL in text field
      @axe.tap_id('addRemoteServer.urlField')
      sleep 0.5
      @axe.type(url)
      sleep 0.5

      # Tap Detect button to identify server type
      @axe.tap_id('addRemoteServer.detectButton')
      sleep 0.5

      # Wait for detection to complete
      result = wait_for_detection(timeout: 20)
      raise "Detection failed: #{result}" if result == :error

      # Enter Yattee Server credentials if available
      username = Config.yattee_server_username
      password = Config.yattee_server_password
      raise 'Yattee Server credentials not configured (set YATTEE_SERVER_USERNAME and YATTEE_SERVER_PASSWORD)' unless username && password

      sleep 0.5

      # Find and fill credential fields by locating text fields after the "Authentication" header
      auth_fields = find_auth_text_fields
      raise 'Could not find username/password fields' if auth_fields.length < 2

      # First field is username, second is password
      username_frame = auth_fields[0]['frame']
      password_frame = auth_fields[1]['frame']

      # Tap and fill username
      @axe.tap_coordinates(
        x: username_frame['x'] + (username_frame['width'] / 2),
        y: username_frame['y'] + (username_frame['height'] / 2)
      )
      sleep 0.3
      @axe.type(username)
      sleep 0.3

      # Tap and fill password
      @axe.tap_coordinates(
        x: password_frame['x'] + (password_frame['width'] / 2),
        y: password_frame['y'] + (password_frame['height'] / 2)
      )
      sleep 0.3
      @axe.type(password)
      sleep 0.3

      # Wait for action button and tap it
      wait_for_element('addRemoteServer.actionButton')
      @axe.tap_id('addRemoteServer.actionButton')
      sleep 0.5

      # Wait for credential validation and sheet dismiss
      wait_for_add_complete(timeout: 20)

      # Close Settings (return to Home)
      close_settings
    end

    # Ensure Yattee Server instance exists (idempotent)
    # @param url [String] Full URL of the Yattee Server
    # @return [Boolean] true if instance was added, false if already existed
    def ensure_yattee_server(url)
      host = URI.parse(url).host

      if yattee_server_exists?(host)
        puts "  Yattee Server instance already exists: #{host}"
        return false
      end

      puts "  Adding Yattee Server instance: #{url}"
      add_yattee_server(url)
      true
    end

    # Remove Yattee Server instance if it exists, then add it fresh
    # @param url [String] Full URL of the Yattee Server
    def remove_and_add_yattee_server(url)
      host = URI.parse(url).host

      if yattee_server_exists?(host)
        puts "  Removing existing Yattee Server instance: #{host}"
        remove_yattee_server(host)
      end

      puts "  Adding Yattee Server instance: #{url}"
      add_yattee_server(url)
    end

    # Remove a Yattee Server instance by host
    # @param host [String] Host portion of the server URL
    def remove_yattee_server(host)
      remove_instance("sources.row.yatteeServer.#{host}", host)
    end

    # Check if Invidious instance exists by navigating to Sources
    # @param host [String] Host portion of the server URL
    # @return [Boolean] true if instance exists
    def invidious_exists?(host)
      navigate_to_sources
      exists = @axe.text_visible?(host)
      close_settings
      exists
    end

    # Add an Invidious instance via Detect & Add flow
    # @param url [String] Full URL of the Invidious instance
    def add_invidious(url)
      add_instance(url)
    end

    # Ensure Invidious instance exists (idempotent)
    # @param url [String] Full URL of the Invidious instance
    # @return [Boolean] true if instance was added, false if already existed
    def ensure_invidious(url)
      host = URI.parse(url).host

      if invidious_exists?(host)
        puts "  Invidious instance already exists: #{host}"
        return false
      end

      puts "  Adding Invidious instance: #{url}"
      add_invidious(url)
      true
    end

    # Remove Invidious instance if it exists, then add it fresh
    # @param url [String] Full URL of the Invidious instance
    def remove_and_add_invidious(url)
      host = URI.parse(url).host

      if invidious_exists?(host)
        puts "  Removing existing Invidious instance: #{host}"
        remove_invidious(host)
      end

      puts "  Adding Invidious instance: #{url}"
      add_invidious(url)
    end

    # Remove an Invidious instance by host
    # @param host [String] Host portion of the server URL
    def remove_invidious(host)
      remove_instance("sources.row.invidious.#{host}", host)
    end

    # Add an Invidious instance whose detection is fronted by HTTP Basic Auth.
    # Drives the new RemoteServerUIState.basicAuthRequired path: type the URL,
    # tap Detect, fill the basic-auth fields when the Retry Detection button
    # appears, retry, then tap Add Source.
    # @param url [String] Full URL of the Invidious instance
    # @param username [String] HTTP Basic Auth username
    # @param password [String] HTTP Basic Auth password
    def add_invidious_with_basic_auth(url, username:, password:)
      navigate_to_sources

      tap_add_source_button
      sleep 0.8

      select_remote_server_tab

      wait_for_element('addRemoteServer.urlField')

      @axe.tap_id('addRemoteServer.urlField')
      sleep 0.5
      @axe.type(url)
      sleep 0.5

      @axe.tap_id('addRemoteServer.detectButton')

      # First detection should fail with .basicAuthRequired — the Retry Detection
      # button appears together with the username/password fields.
      wait_for_element('addRemoteServer.retryDetectionButton', timeout: 20)

      sleep 0.3

      fields = find_basic_auth_text_fields
      raise 'Could not find basic-auth username/password fields' if fields.length < 2

      fill_field(fields[0], username)
      fill_field(fields[1], password)

      @axe.tap_id('addRemoteServer.retryDetectionButton')
      sleep 0.5

      # After a successful retry, detection should reveal the standard form.
      result = wait_for_detection(timeout: 20)
      raise "Detection retry failed: #{result}" if result == :error

      wait_for_element('addRemoteServer.actionButton')
      @axe.tap_id('addRemoteServer.actionButton')
      sleep 0.5

      wait_for_add_complete(timeout: 20)
      close_settings
    end

    # Remove the basic-auth Invidious instance if it exists, then add it fresh.
    # @param url [String] Full URL of the Invidious instance
    # @param username [String] HTTP Basic Auth username
    # @param password [String] HTTP Basic Auth password
    def remove_and_add_invidious_with_basic_auth(url, username:, password:)
      host = URI.parse(url).host

      if invidious_exists?(host)
        puts "  Removing existing Invidious instance: #{host}"
        remove_invidious(host)
      end

      puts "  Adding Invidious instance behind basic auth: #{url}"
      add_invidious_with_basic_auth(url, username: username, password: password)
    end

    # Check if logged in to Invidious instance
    # @param host [String] Host portion of the server URL
    # @return [Boolean] true if logged in
    def invidious_logged_in?(host)
      navigate_to_sources

      # Tap on Invidious instance row to open EditSourceView
      # Due to iOS 26 accessibility issues, use coordinates from the element
      tap_element_containing_text(host)
      sleep 0.8

      # Check if "Log Out" is visible (indicates logged in)
      logged_in = @axe.text_visible?('Log Out')

      # Close the edit sheet
      close_edit_sheet

      logged_in
    end

    # Log in to Invidious instance
    # @param host [String] Host portion of the server URL
    # @return [Boolean] true if login succeeded
    def login_invidious(host)
      email = Config.invidious_email
      password = Config.invidious_password

      raise 'Invidious credentials not configured (set INVIDIOUS_EMAIL and INVIDIOUS_PASSWORD)' unless email && password

      navigate_to_sources

      # Tap on Invidious instance row to open EditSourceView
      # Due to iOS 26 accessibility issues, use coordinates from the element
      tap_element_containing_text(host)
      sleep 0.8

      # Wait for EditSourceView
      wait_for_element('editSource.view')

      # Tap Log in button
      @axe.tap_label('Log in to Account')
      sleep 0.8

      # Wait for login sheet
      wait_for_element('instance.login.view')

      # Enter email/username
      @axe.tap_id('instance.login.usernameField')
      sleep 0.3
      @axe.type(email)
      sleep 0.3

      # Enter password
      @axe.tap_id('instance.login.passwordField')
      sleep 0.3
      @axe.type(password)
      sleep 0.3

      # Tap Sign In button
      @axe.tap_id('instance.login.submitButton')

      # Wait for login to complete (login sheet dismisses, back to edit view)
      start_time = Time.now
      dismiss_attempts = 0
      loop do
        elapsed = (Time.now - start_time).round(1)

        # Check if login succeeded (logout button visible)
        if @axe.text_visible?('Log Out')
          puts "  [#{elapsed}s] Found Log Out button"
          break
        end

        # Check for error
        if @axe.element_exists?('instance.login.error')
          puts '  Login failed with error'
          close_edit_sheet
          return false
        end

        # The iOS "Save Password?" dialog is a system dialog that blocks the accessibility tree
        # When it appears, the app's children become empty or show different content
        tree = @axe.describe_ui
        app_children = tree.is_a?(Array) ? tree.first&.dig('children') : nil
        app_has_no_children = app_children.nil? || app_children.empty?

        # Also check if we're stuck (not on login view, not on edit view with Log Out)
        has_login_view = find_first_element_with_id(tree, 'instance.login.view')

        # Detect password dialog: either empty children OR we're past the login view but don't see Log Out
        password_dialog_likely = app_has_no_children || (!has_login_view && !@axe.text_visible?('Log Out') && elapsed > 1.5)

        if password_dialog_likely && elapsed > 1.0 && dismiss_attempts < 20
          puts "  [#{elapsed}s] Password dialog likely blocking, attempting dismiss ##{dismiss_attempts + 1}..."
          # Try different approaches to dismiss the password save dialog
          # The iOS "Save Password?" dialog appears at bottom of screen
          # "Not Now" button is typically on the left side of the dialog
          case dismiss_attempts
          when 0
            # Try "Not Now" button - bottom left area for iPhone 17 Pro (393pt width, 852pt height)
            @axe.tap_coordinates(x: 100, y: 750)
          when 1
            # Slightly higher
            @axe.tap_coordinates(x: 100, y: 720)
          when 2
            # Slightly to the right
            @axe.tap_coordinates(x: 130, y: 735)
          when 3
            # Try more to the left
            @axe.tap_coordinates(x: 80, y: 740)
          when 4
            # Try different vertical position
            @axe.tap_coordinates(x: 100, y: 700)
          when 5
            # Try center-left
            @axe.tap_coordinates(x: 120, y: 710)
          when 6
            # Try tapping outside the dialog area
            @axe.tap_coordinates(x: 200, y: 100)
          when 7
            # Try swipe down to dismiss
            @axe.swipe(start_x: 200, start_y: 600, end_x: 200, end_y: 800, duration: 0.3)
          when 8
            # Try Return key which might select default
            @axe.press_key(40)
          when 9
            # Try more coordinates
            @axe.tap_coordinates(x: 90, y: 730)
          when 10
            # Upper part of dialog
            @axe.tap_coordinates(x: 100, y: 680)
          when 11
            # Try space key
            @axe.press_key(44)
          when 12
            # More attempts at common positions
            @axe.tap_coordinates(x: 110, y: 725)
          when 13
            # Tab key to move focus, then enter
            @axe.press_key(43)
            sleep 0.2
            @axe.press_key(40)
          when 14
            # Try ESC key
            @axe.press_key(41)
          when 15
            # Try coordinates for larger dialog variant
            @axe.tap_coordinates(x: 100, y: 780)
          when 16
            # Try far left
            @axe.tap_coordinates(x: 50, y: 740)
          when 17
            # Try middle of screen
            @axe.tap_coordinates(x: 200, y: 740)
          when 18
            # Swipe up
            @axe.swipe(start_x: 200, start_y: 750, end_x: 200, end_y: 400, duration: 0.3)
          when 19
            # Final ESC attempt
            @axe.press_key(41)
          end
          dismiss_attempts += 1
          sleep 0.5
          next
        end

        if Time.now - start_time > 35
          # Dump UI tree for debugging
          puts '  Login timed out - dumping UI tree:'
          puts tree.to_s[0..3000]
          raise 'Login timed out'
        end

        sleep 0.5
      end

      puts '  Login succeeded'

      # Close edit sheet and return to Home
      # After successful login, we're on EditSourceView - need to go back to Sources
      # Try Back button first (for navigation-based sheets)
      begin
        @axe.tap_label('Back')
        sleep 0.5
      rescue UITest::Axe::AxeError
        # Try swipe to go back (edge swipe from left)
        @axe.swipe(start_x: 0, start_y: 400, end_x: 200, end_y: 400, duration: 0.3)
        sleep 0.5
      end

      # Now close the Settings sheet
      close_settings
      sleep 0.5

      # Verify we're back on Home, attempt recovery if not
      unless @axe.text_visible?('Home') || @axe.element_exists?('home.settingsButton')
        dismiss_any_sheets
        sleep 0.5
      end

      true
    end

    # Ensure logged in to Invidious (idempotent)
    # @param host [String] Host portion of the server URL
    # @return [Boolean] true if login was performed, false if already logged in
    def ensure_invidious_logged_in(host)
      if invidious_logged_in?(host)
        puts "  Already logged in to Invidious: #{host}"
        return false
      end

      puts "  Logging in to Invidious: #{host}"
      login_invidious(host)
      true
    end

    # Check if Piped instance exists by navigating to Sources
    # @param host [String] Host portion of the server URL
    # @return [Boolean] true if instance exists
    def piped_exists?(host)
      navigate_to_sources
      exists = @axe.text_visible?(host)
      close_settings
      exists
    end

    # Add a Piped instance via Detect & Add flow
    # @param url [String] Full URL of the Piped instance
    def add_piped(url)
      add_instance(url)
    end

    # Ensure Piped instance exists (idempotent)
    # @param url [String] Full URL of the Piped instance
    # @return [Boolean] true if instance was added, false if already existed
    def ensure_piped(url)
      host = URI.parse(url).host

      if piped_exists?(host)
        puts "  Piped instance already exists: #{host}"
        return false
      end

      puts "  Adding Piped instance: #{url}"
      add_piped(url)
      true
    end

    # Remove Piped instance if it exists, then add it fresh
    # @param url [String] Full URL of the Piped instance
    def remove_and_add_piped(url)
      host = URI.parse(url).host

      if piped_exists?(host)
        puts "  Removing existing Piped instance: #{host}"
        remove_piped(host)
      end

      puts "  Adding Piped instance: #{url}"
      add_piped(url)
    end

    # Remove a Piped instance by host
    # @param host [String] Host portion of the server URL
    def remove_piped(host)
      remove_instance("sources.row.piped.#{host}", host)
    end

    # Check if logged in to Piped instance
    # @param host [String] Host portion of the server URL
    # @return [Boolean] true if logged in
    def piped_logged_in?(host)
      navigate_to_sources

      # Tap on Piped instance row to open EditSourceView
      # Due to iOS 26 accessibility issues, use coordinates from the element
      tap_element_containing_text(host)
      sleep 0.8

      # Check if "Log Out" is visible (indicates logged in)
      logged_in = @axe.text_visible?('Log Out')

      # Close the edit sheet
      close_edit_sheet

      logged_in
    end

    # Log in to Piped instance
    # @param host [String] Host portion of the server URL
    # @return [Boolean] true if login succeeded
    def login_piped(host)
      username = Config.piped_username
      password = Config.piped_password

      raise 'Piped credentials not configured (set PIPED_USERNAME and PIPED_PASSWORD)' unless username && password

      navigate_to_sources

      # Tap on Piped instance row to open EditSourceView
      # Due to iOS 26 accessibility issues, use coordinates from the element
      tap_element_containing_text(host)
      sleep 0.8

      # Wait for EditSourceView
      wait_for_element('editSource.view')

      # Tap Log in button
      @axe.tap_label('Log in to Account')
      sleep 0.8

      # Wait for login sheet
      wait_for_element('instance.login.view')

      # Enter username (Piped uses username, not email)
      @axe.tap_id('instance.login.usernameField')
      sleep 0.3
      @axe.type(username)
      sleep 0.3

      # Enter password
      @axe.tap_id('instance.login.passwordField')
      sleep 0.3
      @axe.type(password)
      sleep 0.3

      # Tap Sign In button
      @axe.tap_id('instance.login.submitButton')

      # Wait for login to complete (login sheet dismisses, back to edit view)
      start_time = Time.now
      dismiss_attempts = 0
      loop do
        elapsed = (Time.now - start_time).round(1)

        # Check if login succeeded (logout button visible)
        if @axe.text_visible?('Log Out')
          puts "  [#{elapsed}s] Found Log Out button"
          break
        end

        # Check for error
        if @axe.element_exists?('instance.login.error')
          puts '  Login failed with error'
          close_edit_sheet
          return false
        end

        # The iOS "Save Password?" dialog is a system dialog that blocks the accessibility tree
        # When it appears, the app's children become empty or show different content
        tree = @axe.describe_ui
        app_children = tree.is_a?(Array) ? tree.first&.dig('children') : nil
        app_has_no_children = app_children.nil? || app_children.empty?

        # Also check if we're stuck (not on login view, not on edit view with Log Out)
        has_login_view = find_first_element_with_id(tree, 'instance.login.view')

        # Detect password dialog: either empty children OR we're past the login view but don't see Log Out
        password_dialog_likely = app_has_no_children || (!has_login_view && !@axe.text_visible?('Log Out') && elapsed > 1.5)

        if password_dialog_likely && elapsed > 1.0 && dismiss_attempts < 20
          puts "  [#{elapsed}s] Password dialog likely blocking, attempting dismiss ##{dismiss_attempts + 1}..."
          # Try different approaches to dismiss the password save dialog
          # The iOS "Save Password?" dialog appears at bottom of screen
          # "Not Now" button is typically on the left side of the dialog
          case dismiss_attempts
          when 0
            # Try "Not Now" button - bottom left area for iPhone 17 Pro (393pt width, 852pt height)
            @axe.tap_coordinates(x: 100, y: 750)
          when 1
            # Slightly higher
            @axe.tap_coordinates(x: 100, y: 720)
          when 2
            # Slightly to the right
            @axe.tap_coordinates(x: 130, y: 735)
          when 3
            # Try more to the left
            @axe.tap_coordinates(x: 80, y: 740)
          when 4
            # Try different vertical position
            @axe.tap_coordinates(x: 100, y: 700)
          when 5
            # Try center-left
            @axe.tap_coordinates(x: 120, y: 710)
          when 6
            # Try tapping outside the dialog area
            @axe.tap_coordinates(x: 200, y: 100)
          when 7
            # Try swipe down to dismiss
            @axe.swipe(start_x: 200, start_y: 600, end_x: 200, end_y: 800, duration: 0.3)
          when 8
            # Try Return key which might select default
            @axe.press_key(40)
          when 9
            # Try more coordinates
            @axe.tap_coordinates(x: 90, y: 730)
          when 10
            # Upper part of dialog
            @axe.tap_coordinates(x: 100, y: 680)
          when 11
            # Try space key
            @axe.press_key(44)
          when 12
            # More attempts at common positions
            @axe.tap_coordinates(x: 110, y: 725)
          when 13
            # Tab key to move focus, then enter
            @axe.press_key(43)
            sleep 0.2
            @axe.press_key(40)
          when 14
            # Try ESC key
            @axe.press_key(41)
          when 15
            # Try coordinates for larger dialog variant
            @axe.tap_coordinates(x: 100, y: 780)
          when 16
            # Try far left
            @axe.tap_coordinates(x: 50, y: 740)
          when 17
            # Try middle of screen
            @axe.tap_coordinates(x: 200, y: 740)
          when 18
            # Swipe up
            @axe.swipe(start_x: 200, start_y: 750, end_x: 200, end_y: 400, duration: 0.3)
          when 19
            # Final ESC attempt
            @axe.press_key(41)
          end
          dismiss_attempts += 1
          sleep 0.5
          next
        end

        if Time.now - start_time > 35
          # Dump UI tree for debugging
          puts '  Login timed out - dumping UI tree:'
          puts tree.to_s[0..3000]
          raise 'Login timed out'
        end

        sleep 0.5
      end

      puts '  Login succeeded'

      # Close edit sheet and return to Home
      # After successful login, we're on EditSourceView - need to go back to Sources
      # Try Back button first (for navigation-based sheets)
      begin
        @axe.tap_label('Back')
        sleep 0.5
      rescue UITest::Axe::AxeError
        # Try swipe to go back (edge swipe from left)
        @axe.swipe(start_x: 0, start_y: 400, end_x: 200, end_y: 400, duration: 0.3)
        sleep 0.5
      end

      # Now close the Settings sheet
      close_settings
      sleep 0.5

      # Verify we're back on Home, attempt recovery if not
      unless @axe.text_visible?('Home') || @axe.element_exists?('home.settingsButton')
        dismiss_any_sheets
        sleep 0.5
      end

      true
    end

    # Ensure logged in to Piped (idempotent)
    # @param host [String] Host portion of the server URL
    # @return [Boolean] true if login was performed, false if already logged in
    def ensure_piped_logged_in(host)
      if piped_logged_in?(host)
        puts "  Already logged in to Piped: #{host}"
        return false
      end

      puts "  Logging in to Piped: #{host}"
      login_piped(host)
      true
    end

    private

    # Close edit source sheet and return to sources list
    def close_edit_sheet
      # Try Cancel button first
      begin
        @axe.tap_label('Cancel')
        sleep 0.5
        return if @axe.element_exists?('sources.view')
      rescue UITest::Axe::AxeError
        # Not found
      end

      # Try swipe down
      @axe.swipe(start_x: 200, start_y: 300, end_x: 200, end_y: 700, duration: 0.3)
      sleep 0.5
    end

    # Tap the first element whose label contains the given text
    # @param text [String] Text to search for in element labels
    def tap_element_containing_text(text)
      tree = @axe.describe_ui
      element = find_element_with_label_text(tree, text)
      raise UITest::Axe::AxeError, "No element found with text '#{text}'" unless element

      frame = element['frame']
      x = frame['x'] + (frame['width'] / 2)
      y = frame['y'] + (frame['height'] / 2)
      @axe.tap_coordinates(x: x, y: y)
    end

    # Recursively find the first element whose AXLabel contains the given text
    def find_element_with_label_text(node, text)
      case node
      when Hash
        if node['AXLabel']&.include?(text) && node['frame']
          return node
        end
        node.each_value do |value|
          result = find_element_with_label_text(value, text)
          return result if result
        end
      when Array
        node.each do |item|
          result = find_element_with_label_text(item, text)
          return result if result
        end
      end
      nil
    end

    # Tap the first element matching an accessibility identifier
    # iOS 26 sometimes returns multiple elements with the same ID (e.g., row children)
    # This finds the first one and taps its center coordinates
    # @param identifier [String] The accessibility identifier to find
    def tap_first_element_with_id(identifier)
      tree = @axe.describe_ui
      element = find_first_element_with_id(tree, identifier)
      raise UITest::Axe::AxeError, "No element found with id '#{identifier}'" unless element

      frame = element['frame']
      x = frame['x'] + (frame['width'] / 2)
      y = frame['y'] + (frame['height'] / 2)
      @axe.tap_coordinates(x: x, y: y)
    end

    # Recursively find the first element with a matching AXUniqueId
    # @param node [Hash, Array] Current node in the tree
    # @param identifier [String] The identifier to match
    # @return [Hash, nil] The element or nil if not found
    def find_first_element_with_id(node, identifier)
      case node
      when Hash
        return node if node['AXUniqueId'] == identifier

        node.each_value do |value|
          result = find_first_element_with_id(value, identifier)
          return result if result
        end
      when Array
        node.each do |item|
          result = find_first_element_with_id(item, identifier)
          return result if result
        end
      end
      nil
    end

    # Generic method to add an instance via Detect & Add flow
    # @param url [String] Full URL of the instance
    def add_instance(url)
      navigate_to_sources

      # Tap Add Source button (toolbar or empty state)
      tap_add_source_button
      sleep 0.8

      # Select Remote Server from the source type list
      select_remote_server_tab

      # Wait for URL field to appear
      wait_for_element('addRemoteServer.urlField')

      # Enter URL in text field
      @axe.tap_id('addRemoteServer.urlField')
      sleep 0.5
      @axe.type(url)
      sleep 0.5

      # Tap Detect button to identify server type
      @axe.tap_id('addRemoteServer.detectButton')
      sleep 0.5

      # Wait for detection to complete
      result = wait_for_detection(timeout: 20)
      raise "Detection failed: #{result}" if result == :error

      # Wait for action button to appear, then tap it
      wait_for_element('addRemoteServer.actionButton')
      @axe.tap_id('addRemoteServer.actionButton')
      sleep 0.5

      # Wait for sheet to dismiss after adding
      wait_for_add_complete(timeout: 20)

      # Close Settings (return to Home)
      close_settings
    end

    # Generic method to remove an instance by host text
    # @param row_id [String] Full accessibility identifier for the row (unused, kept for API compat)
    # @param host [String] Host name for logging and text-based lookup
    # @return [Boolean] true if removed, false if not found
    def remove_instance(row_id, host)
      navigate_to_sources

      # Verify the row exists (using text since accessibilityIdentifier doesn't expose as AXUniqueId)
      unless @axe.text_visible?(host)
        puts "  Instance not found: #{host}"
        close_settings
        return false
      end

      # Swipe left on the row to reveal delete button
      # First, find approximate position of the row (middle of screen, adjust as needed)
      @axe.swipe(start_x: 350, start_y: 200, end_x: 50, end_y: 200, duration: 0.3)
      sleep 0.3

      # Tap the Delete button that appears
      begin
        @axe.tap_label('Delete')
        sleep 0.5
      rescue UITest::Axe::AxeError
        # Try tapping by coordinates if label doesn't work
        @axe.tap_coordinates(x: 370, y: 200)
        sleep 0.5
      end

      # Close Settings
      close_settings
      true
    end

    # Navigate from Home to Settings > Sources
    def navigate_to_sources
      # Ensure we're on Home tab
      ensure_on_home

      # Tap Settings button using accessibility identifier
      @axe.tap_id('home.settingsButton')
      sleep 1

      # Wait for Settings view
      wait_for_element('settings.view')

      # Tap Sources row
      @axe.tap_id('settings.row.sources')
      sleep 1

      # Wait for Sources list to load
      # sources.view works for empty state (ContentUnavailableView)
      # For populated list, check for the "Remote Servers" section header text
      wait_for_sources_list
    end

    # Ensure we're on the Home tab
    def ensure_on_home
      # Check for Home navigation title or a home element
      return if @axe.text_visible?('Home') || @axe.element_exists?('home.settingsButton')

      # Try to dismiss any sheets/modals
      dismiss_any_sheets

      # Final check - use a longer timeout
      wait_for_home
    end

    # Try various methods to dismiss sheets/modals
    def dismiss_any_sheets
      # Try Done button by ID
      begin
        @axe.tap_id('settings.doneButton')
        sleep 0.5
        return if @axe.text_visible?('Home') || @axe.element_exists?('home.settingsButton')
      rescue UITest::Axe::AxeError
        # Not found
      end

      # Try Done by label
      begin
        @axe.tap_id('settings.doneButton')
        sleep 0.5
        return if @axe.text_visible?('Home') || @axe.element_exists?('home.settingsButton')
      rescue UITest::Axe::AxeError
        # Not found
      end

      # Try swipe down to dismiss
      @axe.swipe(start_x: 200, start_y: 300, end_x: 200, end_y: 700, duration: 0.3)
      sleep 0.5
      return if @axe.text_visible?('Home') || @axe.element_exists?('home.settingsButton')

      # Last resort: home button
      @axe.home_button
      sleep 1
    end

    # Wait for Home view to appear
    def wait_for_home(timeout: Config.element_timeout)
      start_time = Time.now

      loop do
        # Check for Home title or a home element
        return true if @axe.text_visible?('Home') || @axe.element_exists?('home.settingsButton')

        raise "Home not found after #{timeout} seconds" if Time.now - start_time > timeout

        sleep 0.3
      end
    end

    # Close Settings sheet - handles navigation back from sub-views
    def close_settings
      # Try swipe down to dismiss the sheet (most reliable)
      @axe.swipe(start_x: 200, start_y: 300, end_x: 200, end_y: 700, duration: 0.3)
      sleep 0.5
      return if @axe.text_visible?('Home') || @axe.element_exists?('home.settingsButton')

      # Try Done button by ID
      begin
        @axe.tap_id('settings.doneButton')
        sleep 0.5
        return
      rescue UITest::Axe::AxeError
        # Not found
      end

      # Try Done by label
      begin
        @axe.tap_id('settings.doneButton')
        sleep 0.5
      rescue UITest::Axe::AxeError
        # Not found
      end
    end

    # Wait for detection to complete
    # @param timeout [Integer] Timeout in seconds (default: 20 for network operations)
    # @return [Symbol] :success or :error
    def wait_for_detection(timeout: 20)
      start_time = Time.now

      loop do
        # Check for success (detected type shown)
        if @axe.element_exists?('addRemoteServer.detectedType')
          puts "  Detection succeeded after #{(Time.now - start_time).round(1)}s"
          return :success
        end

        # Check for error
        if @axe.element_exists?('addRemoteServer.detectionError')
          puts "  Detection failed with error after #{(Time.now - start_time).round(1)}s"
          return :error
        end

        # Check for timeout
        elapsed = Time.now - start_time
        raise "Detection timed out after #{timeout} seconds" if elapsed > timeout

        sleep 0.5
      end
    end

    # Wait for the add source sheet to dismiss after tapping Add Source
    # @param timeout [Integer] Timeout in seconds
    def wait_for_add_complete(timeout: 20)
      start_time = Time.now

      loop do
        # Check if the add form has been dismissed (URL field no longer visible)
        unless @axe.element_exists?('addRemoteServer.urlField')
          puts "  Source added successfully after #{(Time.now - start_time).round(1)}s"
          return
        end

        elapsed = Time.now - start_time
        raise "Adding source timed out after #{timeout} seconds" if elapsed > timeout

        sleep 0.5
      end
    end

    # Wait for Sources list to be visible (works for both empty and populated states)
    def wait_for_sources_list(timeout: Config.element_timeout)
      start_time = Time.now

      loop do
        # Empty state has sources.view on ContentUnavailableView
        return true if @axe.element_exists?('sources.view')

        # Populated state: check for section headers or instance rows
        return true if @axe.text_visible?('Remote Servers')
        return true if @axe.text_visible?('Local & Network')

        raise "Sources list not found after #{timeout} seconds" if Time.now - start_time > timeout

        sleep 0.3
      end
    end

    # Find the authentication text fields (username/password) after the "Authentication" header
    # @return [Array<Hash>] Array of text field elements
    def find_auth_text_fields
      tree = @axe.describe_ui
      fields = []
      found_auth_header = false

      collect_auth_fields = lambda do |node|
        return unless node.is_a?(Hash)

        # Look for "Authentication" heading
        if node['role'] == 'AXHeading' && node['AXLabel']&.include?('Authentication')
          found_auth_header = true
        end

        # Collect text fields after the auth header
        if found_auth_header && node['role'] == 'AXTextField'
          fields << node
        end

        # Stop after finding the action button (past the auth section)
        return if node['AXUniqueId'] == 'addRemoteServer.actionButton'

        (node['children'] || []).each { |child| collect_auth_fields.call(child) }
      end

      if tree.is_a?(Array)
        tree.each { |root| collect_auth_fields.call(root) }
      end

      fields
    end

    # Find the username/password text fields under the "HTTP Basic Authentication"
    # section header in AddRemoteServerView. Mirrors find_auth_text_fields but
    # anchored on the localized header used for non-Yattee-Server instance types.
    # Falls back to a plain "Authentication" header for Yattee Server forms.
    # @return [Array<Hash>] Array of text field elements
    def find_basic_auth_text_fields
      tree = @axe.describe_ui
      fields = []
      found_header = false

      collect = lambda do |node|
        return unless node.is_a?(Hash)

        if node['role'] == 'AXHeading' &&
           (node['AXLabel']&.include?('HTTP Basic Authentication') ||
            node['AXLabel']&.include?('Authentication'))
          found_header = true
        end

        if found_header && (node['role'] == 'AXTextField' || node['role'] == 'AXSecureTextField')
          fields << node
        end

        return if node['AXUniqueId'] == 'addRemoteServer.actionButton' ||
                  node['AXUniqueId'] == 'addRemoteServer.retryDetectionButton'

        (node['children'] || []).each { |child| collect.call(child) }
      end

      if tree.is_a?(Array)
        tree.each { |root| collect.call(root) }
      end

      fields
    end

    # Tap a text field discovered via the accessibility tree and type into it.
    # @param field [Hash] Element hash from describe_ui (must have a 'frame')
    # @param text [String] Text to type after focusing the field
    def fill_field(field, text)
      frame = field['frame']
      @axe.tap_coordinates(
        x: frame['x'] + (frame['width'] / 2),
        y: frame['y'] + (frame['height'] / 2)
      )
      sleep 0.3
      @axe.type(text)
      sleep 0.3
    end

    # Debug helper to print UI element tree
    def print_element_tree(node, depth = 0)
      return unless node.is_a?(Hash)

      uid = node['AXUniqueId']
      label = node['AXLabel']
      role = node['role']
      frame = node['frame'] || {}
      puts "    #{'  ' * depth}#{uid || '(none)'} [#{role}] (#{frame['x']&.round},#{frame['y']&.round} #{frame['width']&.round}x#{frame['height']&.round}) - #{label}"
      (node['children'] || []).each { |child| print_element_tree(child, depth + 1) }
    end

    # Tap Add Source button - handles both empty state (body button) and non-empty state (toolbar button)
    def tap_add_source_button
      # Try the body button label first (works for empty state)
      begin
        @axe.tap_label('Add Source')
        return
      rescue UITest::Axe::AxeError
        # Not found - try toolbar button
      end

      # Try the toolbar + button by ID
      begin
        @axe.tap_id('sources.addButton')
        return
      rescue UITest::Axe::AxeError
        # Not found - try coordinates
      end

      # Fallback: tap the toolbar + button by coordinates (top-right area)
      # On iPhone 17 Pro (402pt width), the + button is in the nav bar at ~(370, 93)
      @axe.tap_coordinates(x: 370, y: 93)
    end

    # Navigate to the Remote Server form in the AddSourceView
    # The AddSourceView shows a list of source types
    def select_remote_server_tab
      @axe.tap_label('Add Remote Server')
      sleep 0.5
    end

    # Wait for an element to appear
    # @param identifier [String] Accessibility identifier
    # @param timeout [Integer] Timeout in seconds
    def wait_for_element(identifier, timeout: Config.element_timeout)
      start_time = Time.now

      loop do
        return true if @axe.element_exists?(identifier)

        raise "Element '#{identifier}' not found after #{timeout} seconds" if Time.now - start_time > timeout

        sleep 0.3
      end
    end
  end
end
