require "shellwords"
require "open3"
require "json"
require "fileutils"
require "tmpdir"
require "tempfile"
require "cgi"
require "digest/md5"

default_platform(:ios)

APP_STORE_APP_IDENTIFIER = "ai.openclawfoundation.app"
DEFAULT_APP_STORE_CONNECT_KEYCHAIN_SERVICE = "openclaw-app-store-connect-key"
DEFAULT_SNAPSHOT_DEVICE_FAMILIES = [
  {
    label: "iPhone",
    patterns: [
      /\AiPhone .* Pro Max\z/,
      /\AiPhone .* Plus\z/,
      /\AiPhone .*\z/
    ]
  },
  {
    label: "13-inch iPad",
    patterns: [
      /\AiPad Pro 13-inch/,
      /\AiPad Air 13-inch/,
      /\AiPad .*13-inch/
    ]
  }
].freeze
DEFAULT_WATCH_SNAPSHOT_DEVICE = "Apple Watch Ultra 3 (49mm)"
WATCH_SCREENSHOT_MODE_DEFAULTS_KEY = "openclaw.watch.screenshotMode"
WATCH_SNAPSHOT_STATUS_BAR_TIME = "09:41"
SNAPSHOT_STATUS_BAR_ARGUMENTS = "--time 09:41 --dataNetwork wifi --wifiMode active --wifiBars 3 --cellularMode active --cellularBars 4 --batteryState charged --batteryLevel 100".freeze
REQUIRED_SCREENSHOT_FAMILIES = {
  "iPhone" => /iPhone/,
  "13-inch iPad" => /iPad (Air|Pro) 13-inch/
}.freeze
PUBLIC_METADATA_FILENAMES = [
  "description.txt",
  "keywords.txt",
  "marketing_url.txt",
  "name.txt",
  "privacy_url.txt",
  "promotional_text.txt",
  "release_notes.txt",
  "subtitle.txt",
  "support_url.txt"
].freeze
APP_REVIEW_NOTES_METADATA_FILENAMES = [
  "notes.txt",
  "review_notes.txt"
].freeze
APP_STORE_SCREENSHOT_LIMIT_PER_SET = 10
APP_STORE_SCREENSHOT_SET_DELETE_TIMEOUT_SECONDS = 120
APP_STORE_SCREENSHOT_PROCESSING_TIMEOUT_SECONDS = 3600
APP_STORE_SCREENSHOT_PROCESSING_POLL_SECONDS = 5

def load_env_file(path)
  return unless File.exist?(path)

  File.foreach(path) do |line|
    stripped = line.strip
    next if stripped.empty? || stripped.start_with?("#")

    key, value = stripped.split("=", 2)
    next if key.nil? || key.empty? || value.nil?

    ENV[key] = value if ENV[key].nil? || ENV[key].strip.empty?
  end
end

def env_present?(value)
  !value.nil? && !value.strip.empty?
end

def clear_empty_env_var(key)
  return unless ENV.key?(key)
  ENV.delete(key) unless env_present?(ENV[key])
end

def screenshot_upload_requested?
  ENV["DELIVER_SCREENSHOTS"] == "1"
end

def release_notes_upload_requested?
  ENV["DELIVER_RELEASE_NOTES"] == "1"
end

def validate_required_screenshots!(paths)
  missing_families = REQUIRED_SCREENSHOT_FAMILIES.filter_map do |name, pattern|
    name unless paths.any? { |path| File.basename(path).match?(pattern) }
  end
  return if missing_families.empty?

  UI.user_error!("DELIVER_SCREENSHOTS=1 but screenshots are missing for: #{missing_families.join(', ')}.")
end

def snapshot_devices
  raw = ENV["OPENCLAW_SNAPSHOT_DEVICES"].to_s.strip
  return default_snapshot_devices if raw.empty?

  raw.split(",").map(&:strip).reject(&:empty?)
end

def default_snapshot_devices
  names = available_simulator_devices.map { |device| device["name"].to_s }.reject(&:empty?).uniq

  DEFAULT_SNAPSHOT_DEVICE_FAMILIES.map do |family|
    match = family.fetch(:patterns).filter_map do |pattern|
      names.find { |name| name.match?(pattern) }
    end.first
    UI.user_error!("No available #{family.fetch(:label)} simulator found for App Store screenshots.") if match.nil?
    match
  end
end

def watch_snapshot_device
  raw = ENV["OPENCLAW_WATCH_SNAPSHOT_DEVICE"].to_s.strip
  raw.empty? ? DEFAULT_WATCH_SNAPSHOT_DEVICE : raw
end

def available_simulator_devices
  stdout, stderr, status = Open3.capture3("xcrun", "simctl", "list", "devices", "available", "--json")
  unless status.success?
    detail = stderr.to_s.strip
    detail = stdout.to_s.strip if detail.empty?
    UI.user_error!("Failed to list simulator devices: #{detail}")
  end

  JSON.parse(stdout).fetch("devices").values.flatten
rescue JSON::ParserError => e
  UI.user_error!("Invalid JSON from simctl device list: #{e.message}")
end

def resolve_simulator_device(name)
  devices = available_simulator_devices
  exact = devices.find { |device| device["name"] == name }
  return exact if exact

  watch_devices = devices.select { |device| device["name"].to_s.include?("Apple Watch") }
  fallback = watch_devices.find { |device| device["name"].to_s.include?("Ultra") } || watch_devices.first
  UI.user_error!("No available Apple Watch simulators found.") unless fallback

  UI.important("Apple Watch simulator '#{name}' was not found; using '#{fallback.fetch("name")}'.")
  fallback
end

def install_ready_for_review_edit_state_lookup!
  require "spaceship"

  app_class = Spaceship::ConnectAPI::App
  app_class.class_eval do
    unless method_defined?(:openclaw_get_edit_app_store_version_without_ready_for_review)
      alias_method :openclaw_get_edit_app_store_version_without_ready_for_review, :get_edit_app_store_version
    end

    unless method_defined?(:openclaw_fetch_edit_app_info_without_ready_for_review)
      alias_method :openclaw_fetch_edit_app_info_without_ready_for_review, :fetch_edit_app_info
    end

    def get_edit_app_store_version(client: nil, platform: nil, includes: Spaceship::ConnectAPI::AppStoreVersion::ESSENTIAL_INCLUDES)
      version = openclaw_get_edit_app_store_version_without_ready_for_review(client: client, platform: platform, includes: includes)
      return version if version

      # First public releases can leave the only version in READY_FOR_REVIEW.
      # Fastlane 2.236.1 excludes that state and then tries to create an illegal
      # second version; use the existing review-ready version as the edit target.
      client ||= Spaceship::ConnectAPI
      platform ||= Spaceship::ConnectAPI::Platform::IOS
      filter = {
        appVersionState: Spaceship::ConnectAPI::AppStoreVersion::AppVersionState::READY_FOR_REVIEW,
        platform: platform
      }

      get_app_store_versions(client: client, filter: filter, includes: includes)
        .sort_by { |candidate| Gem::Version.new(candidate.version_string) }
        .last
    end

    def fetch_edit_app_info(client: nil, includes: Spaceship::ConnectAPI::AppInfo::ESSENTIAL_INCLUDES)
      app_info = openclaw_fetch_edit_app_info_without_ready_for_review(client: client, includes: includes)
      return app_info if app_info

      client ||= Spaceship::ConnectAPI
      client
        .get_app_infos(app_id: id, includes: includes)
        .to_models
        .find { |candidate| candidate.state == Spaceship::ConnectAPI::AppInfo::State::READY_FOR_REVIEW }
    end
  end
end

def bundle_identifier_for_product(product_path)
  info_plist_path = File.join(product_path, "Info.plist")
  UI.user_error!("Expected Info.plist at #{info_plist_path}.") unless File.exist?(info_plist_path)

  stdout, stderr, status = Open3.capture3(
    "/usr/libexec/PlistBuddy",
    "-c",
    "Print:CFBundleIdentifier",
    info_plist_path
  )
  unless status.success?
    detail = stderr.to_s.strip
    detail = stdout.to_s.strip if detail.empty?
    UI.user_error!("Failed to read bundle identifier from #{info_plist_path}: #{detail}")
  end

  bundle_identifier = stdout.to_s.strip
  UI.user_error!("Missing bundle identifier in #{info_plist_path}.") if bundle_identifier.empty?
  bundle_identifier
end

def write_watch_screenshot_mode_defaults(udid, bundle_identifiers)
  bundle_identifiers.each do |bundle_identifier|
    sh(
      shell_join([
        "xcrun",
        "simctl",
        "spawn",
        udid,
        "defaults",
        "write",
        bundle_identifier,
        WATCH_SCREENSHOT_MODE_DEFAULTS_KEY,
        "-bool",
        "YES",
      ])
    )
  end
end

def clear_watch_screenshot_mode_defaults(udid, bundle_identifiers)
  bundle_identifiers.each do |bundle_identifier|
    sh(
      "#{shell_join([
        "xcrun",
        "simctl",
        "spawn",
        udid,
        "defaults",
        "delete",
        bundle_identifier,
        WATCH_SCREENSHOT_MODE_DEFAULTS_KEY,
      ])} >/dev/null 2>&1 || true"
    )
  end
end

def status_bar_unsupported?(detail)
  detail.include?("Status bar overrides not supported on this platform") ||
    detail.include?("Operation not supported")
end

def set_watch_status_bar_override(udid)
  stdout, stderr, status = Open3.capture3(
    "xcrun",
    "simctl",
    "status_bar",
    udid,
    "override",
    "--time",
    WATCH_SNAPSHOT_STATUS_BAR_TIME
  )
  return true if status.success?

  detail = stderr.to_s.strip
  detail = stdout.to_s.strip if detail.empty?
  if status_bar_unsupported?(detail)
    UI.important("watchOS simulator status bar overrides are not supported; Watch screenshot clock will use simulator time.")
    return false
  end

  UI.user_error!("Failed to override Watch simulator status bar: #{detail}")
end

def clear_watch_status_bar_override(udid)
  stdout, stderr, status = Open3.capture3("xcrun", "simctl", "status_bar", udid, "clear")
  return if status.success?

  detail = stderr.to_s.strip
  detail = stdout.to_s.strip if detail.empty?
  UI.user_error!("Failed to clear Watch simulator status bar override: #{detail}") unless status_bar_unsupported?(detail)
end

def normalize_watch_screenshot_status_bar(path)
  script = <<~SWIFT
    import AppKit
    import Foundation
    import ImageIO

    let path = CommandLine.arguments[1]
    let timeText = CommandLine.arguments[2]

    guard let source = NSImage(contentsOfFile: path),
          let cgImage = source.cgImage(forProposedRect: nil, context: nil, hints: nil)
    else {
      fputs("Failed to load screenshot at \\(path)\\n", stderr)
      exit(2)
    }

    let width = cgImage.width
    let height = cgImage.height
    let drawWidth = CGFloat(width)
    let drawHeight = CGFloat(height)
    let colorSpace = CGColorSpace(name: CGColorSpace.sRGB) ?? CGColorSpaceCreateDeviceRGB()
    guard let bitmapContext = CGContext(
      data: nil,
      width: width,
      height: height,
      bitsPerComponent: 8,
      bytesPerRow: width * 4,
      space: colorSpace,
      bitmapInfo: CGImageAlphaInfo.noneSkipLast.rawValue)
    else {
      fputs("Failed to create normalized screenshot bitmap at \\(path)\\n", stderr)
      exit(3)
    }

    let graphicsContext = NSGraphicsContext(cgContext: bitmapContext, flipped: false)
    NSGraphicsContext.saveGraphicsState()
    NSGraphicsContext.current = graphicsContext
    NSColor.black.setFill()
    NSBezierPath(rect: NSRect(x: 0, y: 0, width: drawWidth, height: drawHeight)).fill()
    source.draw(
      in: NSRect(x: 0, y: 0, width: drawWidth, height: drawHeight),
      from: NSRect(x: 0, y: 0, width: drawWidth, height: drawHeight),
      operation: .sourceOver,
      fraction: 1.0)

    NSColor.black.setFill()
    NSBezierPath(rect: NSRect(x: drawWidth - 146, y: drawHeight - 92, width: 124, height: 70)).fill()

    let paragraphStyle = NSMutableParagraphStyle()
    paragraphStyle.alignment = .right
    let attributes: [NSAttributedString.Key: Any] = [
      .font: NSFont.monospacedDigitSystemFont(ofSize: 34, weight: .semibold),
      .foregroundColor: NSColor.white,
      .paragraphStyle: paragraphStyle,
    ]
    timeText.draw(
      in: NSRect(x: drawWidth - 134, y: drawHeight - 82, width: 102, height: 44),
      withAttributes: attributes)
    NSGraphicsContext.restoreGraphicsState()

    guard let output = bitmapContext.makeImage(),
          let destination = CGImageDestinationCreateWithURL(
            URL(fileURLWithPath: path) as CFURL,
            "public.png" as CFString,
            1,
            nil)
    else {
      fputs("Failed to encode normalized screenshot at \\(path)\\n", stderr)
      exit(4)
    }

    CGImageDestinationAddImage(destination, output, nil)
    guard CGImageDestinationFinalize(destination) else {
      fputs("Failed to write normalized screenshot at \\(path)\\n", stderr)
      exit(5)
    }
  SWIFT

  Tempfile.create(["openclaw-watch-status-bar", ".swift"]) do |file|
    file.write(script)
    file.flush
    sh(shell_join(["xcrun", "swift", file.path, path, "9:41"]))
  end
end

def capture_watch_screenshot
  device = resolve_simulator_device(watch_snapshot_device)
  device_name = device.fetch("name")
  udid = device.fetch("udid")
  output_dir = File.join(ios_root, "fastlane", "screenshots", "en-US")
  output_path = File.join(output_dir, "#{device_name}-01-now-face.png")
  derived_data_path = File.join(ios_root, "build", "WatchScreenshotDerivedData")
  app_path = File.join(derived_data_path, "Build", "Products", "Debug-watchsimulator", "OpenClawWatchApp.app")

  FileUtils.mkdir_p(output_dir)
  Dir[File.join(output_dir, "Apple Watch*-*.png")].each { |path| FileUtils.rm_f(path) }
  FileUtils.rm_rf(derived_data_path)

  # Single-target watch apps only expose generic simulator build destinations in Xcode.
  # Keep the selected UDID for install/launch/screenshot below.
  sh(
    xcodebuild_shell_join([
      "xcodebuild",
      "-project",
      File.join(ios_root, "OpenClaw.xcodeproj"),
      "-scheme",
      "OpenClawWatchApp",
      "-configuration",
      "Debug",
      "-destination",
      "generic/platform=watchOS Simulator",
      "-derivedDataPath",
      derived_data_path,
      "build",
    ])
  )

  UI.user_error!("Watch screenshot build did not produce #{app_path}.") unless File.exist?(app_path)
  watch_app_identifier = bundle_identifier_for_product(app_path)
  screenshot_mode_bundle_identifiers = [watch_app_identifier]

  sh("#{shell_join(["xcrun", "simctl", "boot", udid])} >/dev/null 2>&1 || true")
  sh(shell_join(["xcrun", "simctl", "bootstatus", udid, "-b"]))
  sh("#{shell_join(["xcrun", "simctl", "uninstall", udid, watch_app_identifier])} >/dev/null 2>&1 || true")
  status_bar_overridden = false
  begin
    sh(shell_join(["xcrun", "simctl", "install", udid, app_path]))
    write_watch_screenshot_mode_defaults(udid, screenshot_mode_bundle_identifiers)
    status_bar_overridden = set_watch_status_bar_override(udid)
    sh(
      "SIMCTL_CHILD_OPENCLAW_WATCH_SCREENSHOT_MODE=1 #{shell_join([
        "xcrun",
        "simctl",
        "launch",
        udid,
        watch_app_identifier,
        "--openclaw-watch-screenshot-mode",
      ])}"
    )
    sleep(3)
    sh(shell_join(["xcrun", "simctl", "io", udid, "screenshot", output_path]))
    normalize_watch_screenshot_status_bar(output_path)
  ensure
    clear_watch_status_bar_override(udid) if status_bar_overridden
    clear_watch_screenshot_mode_defaults(udid, screenshot_mode_bundle_identifiers)
  end

  UI.success("Captured Apple Watch screenshot: #{output_path}")
  output_path
end

def maybe_decode_hex_keychain_secret(value)
  return value unless env_present?(value)

  candidate = value.strip
  return candidate unless candidate.match?(/\A[0-9a-fA-F]+\z/) && candidate.length.even?

  begin
    decoded = [candidate].pack("H*")
    return candidate unless decoded.valid_encoding?

    # `security find-generic-password -w` can return hex when the stored secret
    # includes newlines/non-printable bytes (like PEM files).
    beginPemMarker = %w[BEGIN PRIVATE KEY].join(" ") # pragma: allowlist secret
    endPemMarker = %w[END PRIVATE KEY].join(" ")
    if decoded.include?(beginPemMarker) || decoded.include?(endPemMarker)
      UI.message("Decoded hex-encoded App Store Connect key content from Keychain.")
      return decoded
    end
  rescue StandardError
    return candidate
  end

  candidate
end

def read_app_store_connect_key_content_from_keychain
  service = ENV["APP_STORE_CONNECT_KEYCHAIN_SERVICE"]
  service = DEFAULT_APP_STORE_CONNECT_KEYCHAIN_SERVICE unless env_present?(service)

  account = ENV["APP_STORE_CONNECT_KEYCHAIN_ACCOUNT"]
  account = ENV["USER"] unless env_present?(account)
  account = ENV["LOGNAME"] unless env_present?(account)
  return nil unless env_present?(account)

  begin
    stdout, _stderr, status = Open3.capture3(
      "security",
      "find-generic-password",
      "-s",
      service,
      "-a",
      account,
      "-w"
    )

    return nil unless status.success?

    key_content = stdout.to_s.strip
    key_content = maybe_decode_hex_keychain_secret(key_content)
    return nil unless env_present?(key_content)

    UI.message("Loaded App Store Connect key content from Keychain service '#{service}' (account '#{account}').")
    key_content
  rescue Errno::ENOENT
    nil
  end
end

def repo_root
  File.expand_path("../../..", __dir__)
end

def ios_root
  File.expand_path("..", __dir__)
end

def preserve_file(path)
  existed = File.exist?(path)
  contents = existed ? File.binread(path) : nil

  yield
ensure
  if existed
    File.binwrite(path, contents)
  else
    FileUtils.rm_f(path)
  end
end

def preserve_local_signing
  preserve_file(File.join(ios_root, ".local-signing.xcconfig")) do
    yield
  end
end

def app_store_signing_manifest
  JSON.parse(File.read(File.join(ios_root, "Config", "AppStoreSigning.json")))
end

def app_store_signing_targets
  app_store_signing_manifest.fetch("targets")
end

def app_store_bundle_identifiers
  app_store_signing_targets.map { |target| target.fetch("bundleId") }
end

def app_store_provisioning_profiles
  app_store_signing_targets.each_with_object({}) do |target, profiles|
    profiles[target.fetch("bundleId")] = target.fetch("profileName")
  end
end

def xml_string(value)
  CGI.escapeHTML(value.to_s)
end

def write_app_store_export_options(path)
  manifest = app_store_signing_manifest
  profile_entries = app_store_provisioning_profiles.map do |bundle_id, profile_name|
    "    <key>#{xml_string(bundle_id)}</key>\n    <string>#{xml_string(profile_name)}</string>"
  end.join("\n")

  File.write(path, <<~PLIST)
    <?xml version="1.0" encoding="UTF-8"?>
    <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "https://www.apple.com/DTDs/PropertyList-1.0.dtd">
    <plist version="1.0">
    <dict>
      <key>method</key>
      <string>app-store-connect</string>
      <key>signingStyle</key>
      <string>manual</string>
      <key>signingCertificate</key>
      <string>Apple Distribution</string>
      <key>teamID</key>
      <string>#{xml_string(manifest.fetch("teamId"))}</string>
      <key>provisioningProfiles</key>
      <dict>
    #{profile_entries}
      </dict>
      <key>destination</key>
      <string>export</string>
      <key>stripSwiftSymbols</key>
      <true/>
      <key>manageAppVersionAndBuildNumber</key>
      <false/>
    </dict>
    </plist>
  PLIST
end

def produce_services_for_target(target)
  services = {}
  if target.fetch("capabilities").include?("PUSH_NOTIFICATIONS")
    services[:push_notification] = "on"
  end
  if target.fetch("capabilities").include?("APP_GROUPS")
    services[:app_group] = "on"
  end
  if target.fetch("capabilities").include?("APP_ATTEST")
    services[:app_attest] = "on"
  end
  services
end

def ensure_release_bundle_ids!
  manifest = app_store_signing_manifest
  app_store_signing_targets.each do |target|
    options = {
      app_identifier: target.fetch("bundleId"),
      app_name: target.fetch("displayName"),
      skip_itc: true,
      team_id: manifest.fetch("teamId")
    }
    services = produce_services_for_target(target)
    options[:enable_services] = services unless services.empty?
    produce(**options)
    unless services.empty?
      modify_services(
        app_identifier: target.fetch("bundleId"),
        services: services,
        team_id: manifest.fetch("teamId")
      )
    end
  end
end

def app_store_match_options(readonly:, target:, api_key:)
  manifest = app_store_signing_manifest
  options = {
    type: manifest.fetch("profileType"),
    app_identifier: target.fetch("bundleId"),
    profile_name: target.fetch("profileName"),
    git_url: manifest.fetch("signingRepo"),
    git_branch: manifest.fetch("signingBranch"),
    platform: "ios",
    team_id: manifest.fetch("teamId"),
    readonly: readonly
  }
  options[:api_key] = api_key if api_key
  options
end

def validate_match_profile_mapping!(target)
  bundle_id = target.fetch("bundleId")
  expected_profile_name = target.fetch("profileName")
  actual = lane_context[SharedValues::MATCH_PROVISIONING_PROFILE_MAPPING] || {}
  actual_profile_name = actual[bundle_id]
  return if actual_profile_name == expected_profile_name

  UI.user_error!(
    "Fastlane match did not resolve the pinned App Store profile for #{bundle_id}: expected #{expected_profile_name}, got #{actual_profile_name || "no match output"}"
  )
end

def match_profile_env_key(target, suffix)
  ["sigh", target.fetch("bundleId"), app_store_signing_manifest.fetch("profileType"), suffix].join("_")
end

def profile_plist_value(profile_path, key_path)
  Tempfile.create(["openclaw-profile", ".plist"]) do |file|
    stdout, stderr, status = Open3.capture3("security", "cms", "-D", "-i", profile_path)
    unless status.success?
      detail = stderr.to_s.strip
      detail = stdout.to_s.strip if detail.empty?
      UI.user_error!("Failed to decode provisioning profile #{profile_path}: #{detail}")
    end

    file.write(stdout)
    file.flush
    value, _plist_stderr, plist_status = Open3.capture3("/usr/libexec/PlistBuddy", "-c", "Print:#{key_path}", file.path)
    return nil unless plist_status.success?

    value.to_s.strip
  end
end

def profile_plist_array_values(profile_path, key_path)
  raw = profile_plist_value(profile_path, key_path)
  return [] unless raw

  raw.lines.map(&:strip).reject do |line|
    line.empty? || line == "Array {" || line == "}"
  end
end

def validate_match_profile_capabilities!(target)
  capabilities = target.fetch("capabilities")
  return if capabilities.empty?

  profile_path = ENV[match_profile_env_key(target, "profile-path")]
  UI.user_error!("Fastlane match did not expose an installed profile path for #{target.fetch("bundleId")}.") unless env_present?(profile_path)

  if capabilities.include?("PUSH_NOTIFICATIONS")
    aps_environment = profile_plist_value(profile_path, "Entitlements:aps-environment")
    if aps_environment != "production"
      UI.user_error!(
        "Provisioning profile #{target.fetch("profileName")} for #{target.fetch("bundleId")} is missing production push entitlement; expected aps-environment=production, got #{aps_environment || "missing"}."
      )
    end
  end

  if capabilities.include?("APP_GROUPS")
    expected_app_groups = target.fetch("appGroups")
    actual_app_groups = profile_plist_array_values(profile_path, "Entitlements:com.apple.security.application-groups")
    missing = expected_app_groups - actual_app_groups
    unless missing.empty?
      UI.user_error!(
        "Provisioning profile #{target.fetch("profileName")} for #{target.fetch("bundleId")} is missing App Groups #{missing.join(", ")}; actual groups: #{actual_app_groups.empty? ? "missing" : actual_app_groups.join(", ")}."
      )
    end
  end

  if capabilities.include?("APP_ATTEST")
    app_attest_environments = profile_plist_array_values(profile_path, "Entitlements:com.apple.developer.devicecheck.appattest-environment")
    unless app_attest_environments.include?("production")
      UI.user_error!(
        "Provisioning profile #{target.fetch("profileName")} for #{target.fetch("bundleId")} is missing production App Attest entitlement; actual environments: #{app_attest_environments.empty? ? "missing" : app_attest_environments.join(", ")}."
      )
    end
  end
end

def sync_app_store_signing!(readonly:)
  api_key = readonly ? nil : app_store_connect_api_key_config
  app_store_signing_targets.each do |target|
    match(**app_store_match_options(readonly: readonly, target: target, api_key: api_key))
    validate_match_profile_mapping!(target)
    validate_match_profile_capabilities!(target)
  end
end

def release_signing_check!
  sync_app_store_signing!(readonly: true)
end

def release_notes_path
  File.join(__dir__, "metadata", "en-US", "release_notes.txt")
end

def release_notes_metadata_path
  source = release_notes_path
  UI.user_error!("Missing release notes at #{source}. Run `pnpm ios:version:sync`.") unless File.exist?(source)

  temp_root = Dir.mktmpdir("openclaw-release-notes")
  target_dir = File.join(temp_root, "en-US")
  FileUtils.mkdir_p(target_dir)
  FileUtils.cp(source, File.join(target_dir, "release_notes.txt"))
  temp_root
end

def app_review_notes_markdown_path
  File.join(ios_root, "APP-REVIEW-NOTES.md")
end

def app_review_notes_pdf_path
  File.join(ios_root, "build", "app-review", "APP-REVIEW-NOTES.pdf")
end

def generate_app_review_notes_pdf!
  source = app_review_notes_markdown_path
  UI.user_error!("Missing App Review notes at #{source}.") unless File.exist?(source)

  output = app_review_notes_pdf_path
  FileUtils.mkdir_p(File.dirname(output))
  sh(shell_join(["xcrun", "swift", File.join(repo_root, "scripts", "ios-app-review-notes-pdf.swift"), source, output]))
  output
end

def assert_no_app_review_notes_field_metadata!(metadata_path)
  notes_dir = File.join(metadata_path, "review_information")
  APP_REVIEW_NOTES_METADATA_FILENAMES.each do |filename|
    path = File.join(notes_dir, filename)
    next unless File.exist?(path)

    UI.user_error!(
      "Refusing to upload App Review Notes metadata from #{path}. " \
        "Maintain the App Store Connect Notes field manually so the live setup code is not stored in this repo."
    )
  end
end

def public_metadata_path
  source = File.join(__dir__, "metadata")
  temp_root = Dir.mktmpdir("openclaw-app-store-metadata")
  Dir.children(source).each do |entry|
    source_entry = File.join(source, entry)
    next unless File.directory?(source_entry)
    next unless PUBLIC_METADATA_FILENAMES.any? { |filename| File.exist?(File.join(source_entry, filename)) }

    FileUtils.cp_r(source_entry, File.join(temp_root, entry))
  end
  temp_root
end

def app_store_screenshot_root
  File.join(__dir__, "screenshots")
end

def app_store_screenshot_manifest
  require "deliver/loader"

  Deliver::Loader.load_app_screenshots(app_store_screenshot_root, false)
end

def resolve_app_store_connect_app(app_identifier:, app_id:)
  require "spaceship"

  app = if env_present?(app_id) && !env_present?(app_identifier)
          Spaceship::ConnectAPI::App.get(app_id: app_id)
        else
          Spaceship::ConnectAPI::App.find(app_identifier || APP_STORE_APP_IDENTIFIER)
        end
  UI.user_error!("Could not find App Store Connect app #{app_identifier || app_id || APP_STORE_APP_IDENTIFIER}.") unless app
  app
end

def resolve_app_store_connect_version(app:, short_version:)
  version = app.get_edit_app_store_version(platform: Spaceship::ConnectAPI::Platform::IOS)
  UI.user_error!("Could not find an editable App Store Connect version for #{app.name}.") unless version
  if version.version_string != short_version
    UI.user_error!(
      "Editable App Store Connect version mismatch for #{app.name}: expected #{short_version}, got #{version.version_string}."
    )
  end
  version
end

def app_store_screenshot_sets_for_display_type(localization:, display_type:)
  localization
    .get_app_screenshot_sets(includes: "appScreenshots")
    .select { |set| set.screenshot_display_type == display_type }
end

def clear_app_store_screenshot_sets!(localization:)
  existing_sets = localization.get_app_screenshot_sets(includes: "appScreenshots")
  return if existing_sets.empty?

  existing_sets.each do |set|
    UI.message("Deleting existing #{localization.locale} #{set.screenshot_display_type} screenshot set #{set.id}.")
    set.delete!
  end

  deadline = Time.now + APP_STORE_SCREENSHOT_SET_DELETE_TIMEOUT_SECONDS
  loop do
    sets = localization.get_app_screenshot_sets(includes: "appScreenshots")
    return if sets.empty?

    if Time.now >= deadline
      UI.user_error!(
        "Timed out waiting for App Store Connect to delete #{localization.locale} screenshot sets: #{sets.map(&:id).join(', ')}."
      )
    end
    sleep(3)
  end
end

def app_store_screenshot_expected_rows(screenshots)
  screenshots.map do |screenshot|
    {
      checksum: Digest::MD5.file(screenshot.path).hexdigest,
      file_name: File.basename(screenshot.path)
    }
  end
end

def app_store_screenshot_actual_rows(app_screenshot_set)
  (app_screenshot_set.app_screenshots || []).map do |screenshot|
    {
      checksum: screenshot.source_file_checksum,
      file_name: screenshot.file_name,
      state: (screenshot.asset_delivery_state || {})["state"]
    }
  end
end

def format_app_store_screenshot_rows(rows)
  rows.map do |row|
    [row[:file_name], row[:checksum], row[:state]].compact.join(" ")
  end.join(", ")
end

def app_store_screenshot_processing_timeout_seconds
  raw = ENV["DELIVER_SCREENSHOT_PROCESSING_TIMEOUT"].to_s.strip
  return APP_STORE_SCREENSHOT_PROCESSING_TIMEOUT_SECONDS if raw.empty?

  unless raw.match?(/\A\d+\z/) && raw.to_i.positive?
    UI.user_error!("Invalid DELIVER_SCREENSHOT_PROCESSING_TIMEOUT '#{raw}'. Expected a positive number of seconds.")
  end
  raw.to_i
end

def app_store_screenshot_state_counts(screenshots)
  screenshots.each_with_object({}) do |screenshot, counts|
    state = (screenshot.asset_delivery_state || {})["state"] || "UNKNOWN"
    counts[state] ||= 0
    counts[state] += 1
  end
end

def wait_for_app_store_screenshots_processing!(screenshot_ids:, locale:, display_type:)
  timeout_seconds = app_store_screenshot_processing_timeout_seconds
  deadline = Time.now + timeout_seconds
  loop do
    screenshots = screenshot_ids.map do |screenshot_id|
      Spaceship::ConnectAPI.get_app_screenshot(app_screenshot_id: screenshot_id).first
    end

    failed = screenshots.select(&:error?)
    unless failed.empty?
      details = failed.map { |screenshot| "#{screenshot.file_name}: #{screenshot.error_messages.join(', ')}" }
      UI.user_error!("App Store Connect failed processing #{locale} #{display_type} screenshots: #{details.join('; ')}.")
    end
    return screenshots if screenshots.all?(&:complete?)

    if Time.now >= deadline
      states = app_store_screenshot_state_counts(screenshots)
      UI.user_error!(
        "Timed out after #{timeout_seconds}s waiting for App Store Connect to process #{locale} #{display_type} screenshots: #{states}."
      )
    end

    UI.verbose("Waiting for #{locale} #{display_type} screenshots to finish processing: #{app_store_screenshot_state_counts(screenshots)}.")
    sleep(APP_STORE_SCREENSHOT_PROCESSING_POLL_SECONDS)
  end
end

def validate_app_store_screenshot_target_counts!(screenshots_by_target)
  screenshots_by_target.each do |(locale, display_type), screenshots|
    next if screenshots.length <= APP_STORE_SCREENSHOT_LIMIT_PER_SET

    UI.user_error!(
      "Found #{screenshots.length} screenshots for #{locale} #{display_type}; App Store Connect allows #{APP_STORE_SCREENSHOT_LIMIT_PER_SET}."
    )
  end
end

def verify_app_store_screenshot_set!(app_screenshot_set:, screenshots:, locale:, display_type:)
  expected = app_store_screenshot_expected_rows(screenshots)
  timeout_seconds = app_store_screenshot_processing_timeout_seconds
  deadline = Time.now + timeout_seconds
  actual = []

  loop do
    app_screenshot_set = Spaceship::ConnectAPI::AppScreenshotSet.get(app_screenshot_set_id: app_screenshot_set.id)
    actual = app_store_screenshot_actual_rows(app_screenshot_set)
    actual_identity = actual.map { |row| { checksum: row[:checksum], file_name: row[:file_name] } }
    incomplete = actual.reject { |row| row[:state] == "COMPLETE" }

    return if actual_identity == expected && incomplete.empty?

    if actual.length > expected.length
      UI.user_error!(
        "App Store Connect screenshot verification failed for #{locale} #{display_type}. " \
          "Expected: #{format_app_store_screenshot_rows(expected)}. " \
          "Actual: #{format_app_store_screenshot_rows(actual)}."
      )
    end

    if Time.now >= deadline
      UI.user_error!(
        "Timed out after #{timeout_seconds}s waiting for App Store Connect screenshot verification for #{locale} #{display_type}. " \
          "Expected: #{format_app_store_screenshot_rows(expected)}. " \
          "Actual: #{format_app_store_screenshot_rows(actual)}."
      )
    end

    UI.verbose(
      "Waiting for App Store Connect screenshot verification for #{locale} #{display_type}: " \
        "#{format_app_store_screenshot_rows(actual)}."
    )
    sleep(APP_STORE_SCREENSHOT_PROCESSING_POLL_SECONDS)
  end
end

def replace_app_store_screenshot_set!(localization:, display_type:, screenshots:)
  existing_sets = app_store_screenshot_sets_for_display_type(localization: localization, display_type: display_type)
  unless existing_sets.empty?
    UI.user_error!(
      "App Store Connect still has #{localization.locale} #{display_type} screenshot sets after reset: #{existing_sets.map(&:id).join(', ')}."
    )
  end

  UI.message("Creating #{localization.locale} #{display_type} screenshot set.")
  app_screenshot_set = localization.create_app_screenshot_set(attributes: { screenshotDisplayType: display_type })
  uploaded_ids = screenshots.map.with_index do |screenshot, index|
    started_at = Time.now
    uploaded = app_screenshot_set.upload_screenshot(path: screenshot.path, wait_for_processing: false)
    UI.message(
      "Uploaded #{localization.locale} #{display_type} screenshot #{index + 1}/#{screenshots.length}: " \
        "#{File.basename(screenshot.path)} (#{(Time.now - started_at).round(1)}s)."
    )
    uploaded.id
  end
  wait_for_app_store_screenshots_processing!(
    screenshot_ids: uploaded_ids,
    locale: localization.locale,
    display_type: display_type
  )

  app_screenshot_set = Spaceship::ConnectAPI::AppScreenshotSet.get(app_screenshot_set_id: app_screenshot_set.id)
  app_screenshot_set = app_screenshot_set.reorder_screenshots(app_screenshot_ids: uploaded_ids)
  verify_app_store_screenshot_set!(
    app_screenshot_set: app_screenshot_set,
    screenshots: screenshots,
    locale: localization.locale,
    display_type: display_type
  )
end

# Fastlane deliver can duplicate complete screenshots when its verification retry
# runs before App Store Connect consistently lists processed assets. Keep the
# screenshot write path serial and assert the remote set equals the local files.
def upload_app_store_screenshots_deterministically!(app_identifier:, app_id:, short_version:, screenshots:)
  app = resolve_app_store_connect_app(app_identifier: app_identifier, app_id: app_id)
  version = resolve_app_store_connect_version(app: app, short_version: short_version)
  localizations_by_locale = version.get_app_store_version_localizations.each_with_object({}) do |localization, index|
    index[localization.locale] = localization
  end

  screenshots_by_target = screenshots
    .sort_by { |screenshot| [screenshot.language.to_s, screenshot.display_type.to_s, File.basename(screenshot.path)] }
    .group_by { |screenshot| [screenshot.language, screenshot.display_type] }
  validate_app_store_screenshot_target_counts!(screenshots_by_target)

  missing_locales = screenshots_by_target.keys.map(&:first).uniq.reject { |locale| localizations_by_locale.key?(locale) }
  unless missing_locales.empty?
    UI.user_error!(
      "App Store Connect localizations are missing for screenshot locales #{missing_locales.join(', ')}. " \
        "Upload metadata for these locales before uploading screenshots."
    )
  end

  screenshots_by_target.keys.map(&:first).uniq.each do |locale|
    clear_app_store_screenshot_sets!(localization: localizations_by_locale.fetch(locale))
  end

  screenshots_by_target.each do |(locale, display_type), target_screenshots|
    replace_app_store_screenshot_set!(
      localization: localizations_by_locale.fetch(locale),
      display_type: display_type,
      screenshots: target_screenshots
    )
  end

  UI.success("Uploaded and verified #{screenshots.length} App Store screenshots for #{short_version}.")
end

def read_ios_version_metadata
  script_path = File.join(repo_root, "scripts", "ios-version.ts")
  stdout, stderr, status = Open3.capture3(
    "node",
    "--import",
    "tsx",
    script_path,
    "--json",
    chdir: repo_root
  )

  unless status.success?
    detail = stderr.to_s.strip
    detail = stdout.to_s.strip if detail.empty?
    UI.user_error!("Failed to read iOS version metadata: #{detail}")
  end

  parsed = JSON.parse(stdout)
  version = parsed["canonicalVersion"].to_s.strip
  short_version = parsed["marketingVersion"].to_s.strip
  if !env_present?(version) || !env_present?(short_version)
    UI.user_error!("iOS version helper returned incomplete metadata.")
  end

  {
    short_version: short_version,
    version: version
  }
rescue JSON::ParserError => e
  UI.user_error!("Invalid JSON from iOS version helper: #{e.message}")
end

def sync_ios_versioning!
  script_path = File.join(repo_root, "scripts", "ios-sync-versioning.ts")
  stdout, stderr, status = Open3.capture3(
    "node",
    "--import",
    "tsx",
    script_path,
    "--check",
    chdir: repo_root
  )
  return if status.success?

  detail = stderr.to_s.strip
  detail = stdout.to_s.strip if detail.empty?
  UI.user_error!("iOS versioning artifacts are stale. Run `pnpm ios:version:sync`.\n#{detail}")
end

def shell_join(parts)
  Shellwords.join(parts.compact)
end

def xcodebuild_shell_join(parts)
  xcode_path = "/usr/bin:/bin:/usr/sbin:/sbin:/opt/homebrew/bin:/usr/local/bin"
  shell_join(["env", "PATH=#{xcode_path}", *parts])
end

def resolve_release_build_number(api_key:, short_version:)
  explicit = ENV["IOS_RELEASE_BUILD_NUMBER"]
  if env_present?(explicit)
    UI.user_error!("Invalid iOS release build number '#{explicit}'. Expected digits only.") unless explicit.match?(/\A\d+\z/)
    UI.message("Using explicit iOS release build number #{explicit}.")
    return explicit
  end

  latest_build = latest_testflight_build_number(
    api_key: api_key,
    app_identifier: APP_STORE_APP_IDENTIFIER,
    version: short_version,
    initial_build_number: 0
  )
  next_build = latest_build.to_i + 1
  UI.message("Resolved iOS release build number #{next_build} for #{short_version} (latest App Store Connect build: #{latest_build}).")
  next_build.to_s
end

def release_build_number_needs_app_store_connect_auth?
  explicit = ENV["IOS_RELEASE_BUILD_NUMBER"]
  !env_present?(explicit)
end

def prepare_app_store_release!(version:, build_number:)
  script_path = File.join(repo_root, "scripts", "ios-release-prepare.sh")
  UI.message("Preparing iOS App Store release #{version} (build #{build_number}).")
  sh(shell_join(["bash", script_path, "--build-number", build_number]))

  release_xcconfig = File.join(ios_root, "build", "AppStoreRelease.xcconfig")
  UI.user_error!("Missing App Store release xcconfig at #{release_xcconfig}.") unless File.exist?(release_xcconfig)

  ENV["XCODE_XCCONFIG_FILE"] = release_xcconfig
  release_xcconfig
end

def mobile_release_ref_script
  File.join(repo_root, "scripts", "mobile-release-ref.ts")
end

def release_git_sha
  stdout, stderr, status = Open3.capture3("git", "rev-parse", "HEAD", chdir: repo_root)
  UI.user_error!("Unable to resolve release Git SHA: #{stderr.strip}") unless status.success?
  stdout.strip
end

def mobile_release_ref_command(command, platform:, version:, build: nil, version_code: nil, sha: nil)
  args = [
    "node",
    "--import",
    "tsx",
    mobile_release_ref_script,
    command,
    "--platform",
    platform,
    "--version",
    version,
    "--root",
    repo_root,
  ]
  args.push("--build", build.to_s) if build
  args.push("--version-code", version_code.to_s) if version_code
  args.push("--sha", sha.to_s) if sha
  sh(shell_join(args))
end

def ensure_mobile_release_ref_available!(platform:, version:, build: nil, version_code: nil, sha: nil)
  mobile_release_ref_command(
    "preflight",
    platform: platform,
    version: version,
    build: build,
    version_code: version_code,
    sha: sha
  )
end

def record_mobile_release_ref!(platform:, version:, build: nil, version_code: nil, sha: nil)
  mobile_release_ref_command(
    "record",
    platform: platform,
    version: version,
    build: build,
    version_code: version_code,
    sha: sha
  )
end

def validate_app_store_ipa!(ipa_path)
  script_path = File.join(repo_root, "scripts", "ios-validate-app-store-ipa.sh")
  sh(shell_join(["bash", script_path, "--ipa", ipa_path]))
end

def build_app_store_release(context)
  version = context[:version]
  project_path = File.join(ios_root, "OpenClaw.xcodeproj")
  output_directory = File.join(ios_root, "build", "app-store")
  archive_path = File.join(output_directory, "OpenClaw-#{version}.xcarchive")
  export_options_path = File.join(output_directory, "ExportOptions.plist")
  output_name = "OpenClaw-#{version}.ipa"
  expected_ipa_path = File.join(output_directory, output_name)

  FileUtils.mkdir_p(output_directory)
  FileUtils.rm_rf(archive_path)
  Dir[File.join(output_directory, "*.ipa")].each { |path| FileUtils.rm_f(path) }
  write_app_store_export_options(export_options_path)

  sh(
    xcodebuild_shell_join([
      "xcodebuild",
      "-project",
      project_path,
      "-scheme",
      "OpenClaw",
      "-configuration",
      "Release",
      "-destination",
      "generic/platform=iOS",
      "-archivePath",
      archive_path,
      "clean",
      "archive",
    ])
  )

  sh(
    xcodebuild_shell_join([
      "xcodebuild",
      "-exportArchive",
      "-archivePath",
      archive_path,
      "-exportPath",
      output_directory,
      "-exportOptionsPlist",
      export_options_path,
    ])
  )

  exported_ipas = Dir[File.join(output_directory, "*.ipa")]
  UI.user_error!("xcodebuild export did not produce an IPA in #{output_directory}.") if exported_ipas.empty?
  UI.user_error!("xcodebuild export produced multiple IPAs in #{output_directory}: #{exported_ipas.join(", ")}") if exported_ipas.length > 1
  exported_ipa = exported_ipas.first
  FileUtils.mv(exported_ipa, expected_ipa_path) unless exported_ipa == expected_ipa_path
  validate_app_store_ipa!(expected_ipa_path)

  {
    archive_path: archive_path,
    build_number: context[:build_number],
    ipa_path: expected_ipa_path,
    short_version: context[:short_version],
    version: version
  }
end

def app_store_connect_api_key_config
  load_env_file(File.join(__dir__, ".env"))
  clear_empty_env_var("APP_STORE_CONNECT_API_KEY_PATH")
  clear_empty_env_var("APP_STORE_CONNECT_KEY_PATH")
  clear_empty_env_var("APP_STORE_CONNECT_KEY_CONTENT")

  api_key = nil

  key_path = ENV["APP_STORE_CONNECT_API_KEY_PATH"]
  if env_present?(key_path)
    api_key = app_store_connect_api_key(path: key_path)
  else
    p8_path = ENV["APP_STORE_CONNECT_KEY_PATH"]
    if env_present?(p8_path)
      key_id = ENV["APP_STORE_CONNECT_KEY_ID"]
      issuer_id = ENV["APP_STORE_CONNECT_ISSUER_ID"]
      UI.user_error!("Missing APP_STORE_CONNECT_KEY_ID or APP_STORE_CONNECT_ISSUER_ID for APP_STORE_CONNECT_KEY_PATH auth.") if [key_id, issuer_id].any? { |v| !env_present?(v) }

      api_key = app_store_connect_api_key(
        key_id: key_id,
        issuer_id: issuer_id,
        key_filepath: p8_path
      )
    else
      key_id = ENV["APP_STORE_CONNECT_KEY_ID"]
      issuer_id = ENV["APP_STORE_CONNECT_ISSUER_ID"]
      key_content = ENV["APP_STORE_CONNECT_KEY_CONTENT"]
      key_content = read_app_store_connect_key_content_from_keychain unless env_present?(key_content)

      UI.user_error!(
        "Missing App Store Connect API key. Set APP_STORE_CONNECT_API_KEY_PATH (json), APP_STORE_CONNECT_KEY_PATH (p8), or APP_STORE_CONNECT_KEY_ID/APP_STORE_CONNECT_ISSUER_ID with APP_STORE_CONNECT_KEY_CONTENT (or Keychain via APP_STORE_CONNECT_KEYCHAIN_SERVICE/APP_STORE_CONNECT_KEYCHAIN_ACCOUNT)."
      ) if [key_id, issuer_id, key_content].any? { |v| !env_present?(v) }

      is_base64 = key_content.include?("BEGIN PRIVATE KEY") ? false : true

      api_key = app_store_connect_api_key(
        key_id: key_id,
        issuer_id: issuer_id,
        key_content: key_content,
        is_key_content_base64: is_base64
      )
    end
  end

  api_key
end

platform :ios do
  private_lane :prepare_app_store_context do |options|
    require_api_key = options[:require_api_key] == true
    needs_api_key = require_api_key || release_build_number_needs_app_store_connect_auth?
    api_key = needs_api_key ? app_store_connect_api_key_config : nil
    sync_ios_versioning!
    version_metadata = read_ios_version_metadata
    version = version_metadata[:version]
    short_version = version_metadata[:short_version]
    build_number = resolve_release_build_number(api_key: api_key, short_version: short_version)
    release_xcconfig = prepare_app_store_release!(version: version, build_number: build_number)

    {
      api_key: api_key,
      build_number: build_number,
      release_xcconfig: release_xcconfig,
      short_version: short_version,
      version: version
    }
  end

  desc "Print the App Store signing plan"
  lane :signing_plan do
    sh(shell_join(["node", File.join(repo_root, "scripts", "ios-release-signing.mjs"), "--mode", "plan"]))
  end

  desc "Check local App Store signing assets through Fastlane match"
  lane :signing_check do
    sync_app_store_signing!(readonly: true)
    UI.success("Fastlane match App Store signing assets are available locally.")
  end

  desc "Create Developer Portal bundle IDs/services and sync App Store signing assets"
  lane :signing_setup do
    ensure_release_bundle_ids!
    sync_app_store_signing!(readonly: false)
    UI.success("Fastlane App Store signing setup is complete.")
  end

  desc "Pull encrypted App Store signing assets from the shared Fastlane match repo"
  lane :signing_sync_pull do
    sync_app_store_signing!(readonly: true)
    UI.success("Pulled Fastlane match App Store signing assets.")
  end

  desc "Create or refresh encrypted App Store signing assets in the shared Fastlane match repo"
  lane :signing_sync_push do
    ensure_release_bundle_ids!
    sync_app_store_signing!(readonly: false)
    UI.success("Pushed Fastlane match App Store signing assets.")
  end

  desc "Build an App Store distribution archive locally without uploading"
  lane :app_store_archive do
    context = prepare_app_store_context(require_api_key: false)
    build = build_app_store_release(context)
    UI.success("Built iOS App Store archive: version=#{build[:version]} short=#{build[:short_version]} build=#{build[:build_number]}")
    build
  ensure
    ENV.delete("XCODE_XCCONFIG_FILE")
  end

  desc "Generate screenshots, update App Store metadata and review attachment, then upload an App Store build"
  lane :release_upload do
    unless ENV["OPENCLAW_IOS_RELEASE_WRAPPER"] == "1"
      UI.user_error!("Use `pnpm ios:release:upload`; direct Fastlane TestFlight upload is disabled.")
    end

    release_sha = release_git_sha
    release_signing_check!
    preserve_local_signing do
      screenshots
    end
    context = prepare_app_store_context(require_api_key: true)
    ensure_mobile_release_ref_available!(
      platform: "ios",
      version: context[:short_version],
      build: context[:build_number],
      sha: release_sha
    )
    ENV["DELIVER_SCREENSHOTS"] = "1"
    ENV["DELIVER_RELEASE_NOTES"] = "1"
    metadata

    build = build_app_store_release(context)

    upload_to_testflight(
      api_key: context[:api_key],
      ipa: build[:ipa_path],
      skip_waiting_for_build_processing: true,
      uses_non_exempt_encryption: false
    )
    record_mobile_release_ref!(
      platform: "ios",
      version: build[:short_version],
      build: build[:build_number],
      sha: release_sha
    )

    UI.success("Uploaded iOS App Store build: version=#{build[:version]} short=#{build[:short_version]} build=#{build[:build_number]}")
    UI.important("App Review submission remains manual in App Store Connect.")
  ensure
    ENV.delete("XCODE_XCCONFIG_FILE")
  end

  desc "Upload App Store metadata, App Review PDF attachment, and optionally screenshots"
  lane :metadata do
    install_ready_for_review_edit_state_lookup!
    sync_ios_versioning!
    version_metadata = read_ios_version_metadata
    api_key = app_store_connect_api_key_config
    clear_empty_env_var("APP_STORE_CONNECT_API_KEY_PATH")
    app_identifier = ENV["APP_STORE_CONNECT_APP_IDENTIFIER"]
    app_id = ENV["APP_STORE_CONNECT_APP_ID"]
    app_identifier = nil unless env_present?(app_identifier)
    app_id = nil unless env_present?(app_id)

    if screenshot_upload_requested?
      screenshots_to_upload = app_store_screenshot_manifest
      if screenshots_to_upload.empty?
        UI.user_error!("DELIVER_SCREENSHOTS=1 but no PNG screenshots were found under apps/ios/fastlane/screenshots.")
      end
      validate_required_screenshots!(screenshots_to_upload.map(&:path))
    end

    assert_no_app_review_notes_field_metadata!(File.join(__dir__, "metadata"))
    metadata_path = public_metadata_path
    skip_metadata = ENV["DELIVER_METADATA"] != "1"
    if release_notes_upload_requested? && skip_metadata
      metadata_path = release_notes_metadata_path
      skip_metadata = false
    end
    assert_no_app_review_notes_field_metadata!(metadata_path) unless skip_metadata
    app_review_attachment_file = skip_metadata ? nil : generate_app_review_notes_pdf!

    deliver_options = {
      api_key: api_key,
      force: true,
      app_version: version_metadata[:short_version],
      copyright: "2026 OpenClaw",
      primary_category: "PRODUCTIVITY",
      secondary_category: "UTILITIES",
      metadata_path: metadata_path,
      skip_screenshots: true,
      skip_metadata: skip_metadata,
      skip_binary_upload: true,
      overwrite_screenshots: false,
      app_review_attachment_file: app_review_attachment_file,
      skip_app_version_update: false,
      submit_for_review: false,
      run_precheck_before_submit: false
    }
    deliver_options[:app_identifier] = app_identifier if app_identifier
    if app_id && app_identifier.nil?
      # `deliver` prefers app_identifier from Appfile unless explicitly blanked.
      deliver_options[:app_identifier] = ""
      deliver_options[:app] = app_id
    end

    deliver(**deliver_options)
    if screenshot_upload_requested?
      upload_app_store_screenshots_deterministically!(
        app_identifier: app_identifier,
        app_id: app_id,
        short_version: version_metadata[:short_version],
        screenshots: screenshots_to_upload
      )
    end
  end

  desc "Generate deterministic iOS screenshots for App Store metadata"
  lane :screenshots do
    sh(shell_join(["bash", File.join(repo_root, "scripts", "ios-configure-signing.sh")]))
    sh(shell_join(["bash", File.join(repo_root, "scripts", "ios-write-version-xcconfig.sh")]))
    sh(shell_join(["xcodegen", "generate", "--spec", File.join(ios_root, "project.yml"), "--project", ios_root]))

    capture_ios_screenshots(
      project: File.join(ios_root, "OpenClaw.xcodeproj"),
      scheme: "OpenClawUITests",
      configuration: "Debug",
      devices: snapshot_devices,
      languages: ["en-US"],
      launch_arguments: ["--openclaw-screenshot-mode"],
      output_directory: File.join(ios_root, "fastlane", "screenshots"),
      clear_previous_screenshots: true,
      reinstall_app: true,
      concurrent_simulators: false,
      override_status_bar: true,
      override_status_bar_arguments: SNAPSHOT_STATUS_BAR_ARGUMENTS,
      skip_open_summary: true,
      xcargs: "-allowProvisioningUpdates"
    )

    watch_screenshot
  end

  desc "Generate deterministic Apple Watch screenshot for App Store metadata"
  lane :watch_screenshot do
    sh(shell_join(["bash", File.join(repo_root, "scripts", "ios-configure-signing.sh")]))
    sh(shell_join(["bash", File.join(repo_root, "scripts", "ios-write-version-xcconfig.sh")]))
    sh(shell_join(["xcodegen", "generate", "--spec", File.join(ios_root, "project.yml"), "--project", ios_root]))
    capture_watch_screenshot
  end

  desc "Validate App Store Connect API auth"
  lane :auth_check do
    app_store_connect_api_key_config
    UI.success("App Store Connect API auth loaded successfully.")
  end
end
