# This file contains the fastlane.tools configuration
# You can find the documentation at https://docs.fastlane.tools
#
# For a list of all available actions, check out
#
#     https://docs.fastlane.tools/actions
#
# For a list of all available plugins, check out
#
#     https://docs.fastlane.tools/plugins/available-plugins
#

require_relative 'api_diff_helper'
require_relative 'performance_baseline_helper'
require 'rexml/document'
require 'json'
require 'open3'

default_platform(:ios)

def current_version_number
  File.read("#{File.dirname(__FILE__)}/../.version").strip
end

# Hack to work around a Fastlane quirk where plugin actions are run from the parent of the current working directory.
# We switch to the first subdirectory, then run the action. The action will run from the parent of the subdirectory, which is
# our actual current working directory.
# This is only needed when our current working directory is not the ./fastlane directory.
# https://docs.fastlane.tools/advanced/fastlane/#directory-behavior
def run_plugin_action_in_current_directory(&block)
  first_subdirectory = Dir["*/"].first
  if first_subdirectory.nil?
    block.call
  else
    Dir.chdir(first_subdirectory.chomp("/"), &block)
  end
end


files_with_version_number = {
  './RevenueCat.podspec' => ['s.version          = "{x}"'],
  './RevenueCatUI.podspec' => ['s.version          = "{x}"'],
  './Sources/Misc/SystemInfo.swift' => ['return "{x}"'],
  './.version' => ['{x}'],
  './scripts/docs/v4_api_migration_guide.html' => ['purchases-ios-docs/{x}/documentation/revenuecat/v4_api_migration_guide'],
  './scripts/docs/v5_api_migration_guide.html' => ['purchases-ios-docs/{x}/documentation/revenuecat/v5_api_migration_guide']
}
PLIST_VERSION_PATTERNS = ["<key>CFBundleShortVersionString</key>\n\t<string>{x}</string>",]
files_with_version_number_without_prerelease_modifiers = {
  './Sources/Info.plist' => PLIST_VERSION_PATTERNS,
  './Tests/BackendIntegrationTestApp/Info.plist' => PLIST_VERSION_PATTERNS,
  './Tests/BackendIntegrationTests/Info.plist' => PLIST_VERSION_PATTERNS,
  './Tests/UnitTests/Info.plist' => PLIST_VERSION_PATTERNS,
  './Tests/UnitTestsHostApp/Info.plist' => PLIST_VERSION_PATTERNS
}

PACKAGE_VERSION_PATTERNS = ["repositoryURL = \"https://github.com/RevenueCat/purchases-ios\";\n\t\t\trequirement = {\n\t\t\t\tkind = upToNextMajorVersion;\n\t\t\t\tminimumVersion = {x};\n"]
files_to_update_on_latest_stable_releases = {
  './scripts/docs/index.html' => ['purchases-ios-docs/{x}/documentation/revenuecat'],
  './Examples/SampleCat/SampleCat.xcodeproj/project.pbxproj' => PACKAGE_VERSION_PATTERNS
}

REPO_NAME = 'purchases-ios'
PAYWALL_PREVIEW_RESOURCES_REPO_NAME = 'paywall-preview-resources'
CHANGELOG_LATEST_PATH = './CHANGELOG.latest.md'
CHANGELOG_PATH = './CHANGELOG.md'
BINARY_SIZE_ANALYSIS_DEVICE = 'iPhone17,1'
BINARY_SIZE_ANALYSIS_CONFIGURATION = 'Release'
SENTRY_ORG_SLUG = 'revenuecat'
SENTRY_PROJECT_SLUG = 'purchases-ios'

platform :ios do
  before_all do
    setup_circle_ci

    if ENV['APP_STORE_CONNECT_API_KEY_ISSUER_ID']
      app_store_connect_api_key # This will create a shared client that all the tools know how to use and will look for
    end
  end

  desc "Bump version, update swift header, edit changelog, and create pull request"
  lane :bump do |options|
    bump_version_update_changelog_create_pr(
      current_version: current_version_number,
      changelog_latest_path: CHANGELOG_LATEST_PATH,
      changelog_path: CHANGELOG_PATH,
      files_to_update: files_with_version_number,
      files_to_update_without_prerelease_modifiers: files_with_version_number_without_prerelease_modifiers,
      files_to_update_on_latest_stable_releases: files_to_update_on_latest_stable_releases,
      repo_name: REPO_NAME,
      github_rate_limit: options[:github_rate_limit],
      editor: options[:editor],
      next_version: options[:next_version],
      automatic_release: options[:automatic_release],
      fallback_pr_lookup: options[:fallback_pr_lookup] == true
    )
    compile_autogenerated_header_commit_and_push(options)
  end

  desc "Automatically bumps version, update swift header, edit changelog, and create pull request"
  lane :automatic_bump do |options|
    next_version, type_of_bump = determine_next_version_using_labels(
      repo_name: REPO_NAME,
      github_rate_limit: options[:github_rate_limit],
      fallback_pr_lookup: options[:fallback_pr_lookup] == true
    )
    options[:next_version] = next_version
    options[:automatic_release] = true
    if type_of_bump == :skip
      UI.message('Skipping automatic bump since the next version doesn\'t include public facing changes')
      next
    end
    if type_of_bump == :major
      UI.message('Skipping automatic bump since the next version is a major release')
      next
    end
    bump(options)
  end

  desc "Make github release"
  lane :github_release do |options|
    create_github_release(
      version: options[:version],
      repo_name: REPO_NAME,
      github_api_token: ENV["GITHUB_TOKEN"],
      changelog_latest_path: CHANGELOG_LATEST_PATH,
      upload_assets: ['RevenueCat.xcframework.zip', 'RevenueCatUI.xcframework.zip']
    )
  end

  desc "Make github release for purchases-ios-admob"
  lane :admob_github_release do |options|
    version = options[:version] || current_version_number
    changelog = Fastlane::Helper::VersioningHelper.auto_generate_changelog(
      REPO_NAME,
      ENV["GITHUB_TOKEN"],
      0,
      false,
      nil,
      nil,
      version,
      filter_labels: ['pr:admob'],
      cross_repo_pr_reference: 'RevenueCat/purchases-ios'
    )

    changelog_path = File.join(Dir.tmpdir, 'CHANGELOG.admob.latest.md')
    File.write(changelog_path, changelog.empty? ? "Release #{version}" : changelog)

    # Resolve the commit SHA for the tag on purchases-ios-admob, since the current
    # repo HEAD belongs to purchases-ios and would be invalid for that repo.
    tag_ref_resp = github_api(
      server_url: 'https://api.github.com',
      api_token: ENV["GITHUB_TOKEN"],
      http_method: 'GET',
      path: "/repos/RevenueCat/purchases-ios-admob/git/ref/tags/#{version}"
    )
    admob_commitish = JSON.parse(tag_ref_resp[:body])['object']['sha']

    create_github_release(
      version: version,
      repo_name: 'purchases-ios-admob',
      github_api_token: ENV["GITHUB_TOKEN"],
      changelog_latest_path: changelog_path,
      commitish: admob_commitish
    )
  end

  desc "Creates PR changing version to next minor adding a -SNAPSHOT suffix"
  lane :prepare_next_version do |options|
    create_next_snapshot_version(
      current_version: current_version_number,
      repo_name: REPO_NAME,
      github_pr_token: ENV["GITHUB_PULL_REQUEST_API_TOKEN"],
      files_to_update: files_with_version_number,
      files_to_update_without_prerelease_modifiers: files_with_version_number_without_prerelease_modifiers
    )
  end


  desc "Setup development environment"
  lane :setup_dev do |options|
    if system("command -v swiftlint > /dev/null")
      UI.message("swiftlint already installed ✅")
    else
      begin
        sh("brew install swiftlint")
      rescue => exception
        UI.error("❌ Please install homebrew and then re-run this lane: /bin/bash -c \"$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)\"")
        raise exception
      end
    end

    Dir.chdir ".." do
      # .git is a file when this repo is checked out as a submodule, so ask git for the real hooks dir
      hooks_dir = sh("git", "rev-parse", "--git-path", "hooks", log: false).strip
      pre_commit_link = File.join(hooks_dir, "pre-commit")

      if File.exist?(pre_commit_link) || File.symlink?(pre_commit_link)
        UI.message("pre-commit script already linked ✅")
      else
        UI.message("Linking pre-commit script 🔗")
        FileUtils.mkdir_p(hooks_dir)
        FileUtils.ln_sf(File.expand_path("scripts/pre-commit.sh"), pre_commit_link)
      end
    end
  end

  desc "Updates paywall-preview-resources-commit to point to latest commit on main and creates/updates PR"
  lane :update_paywall_preview_resources_commit do |options|
    branch_name = "pw2-update-paywall-preview-resources-commit"

    # Ensure we're starting from an up-to-date main.
    sh("git", "fetch", "origin")

    create_or_checkout_branch(branch_name: branch_name)

    new_hash = sh("git", "ls-remote", "https://github.com/RevenueCat/#{PAYWALL_PREVIEW_RESOURCES_REPO_NAME}.git", "refs/heads/main").split("\t").first
    UI.important("Updating paywall preview resources commit to #{new_hash}")
    update_paywall_preview_resources_commit(new_hash)

    commit_push_and_create_pr_if_necessary(
      repo: REPO_NAME,
      branch_name: branch_name,
      commit_message: "[Automated] Updates commit hash of paywall-preview-resources.",
      title: "[AUTOMATIC][Paywalls V2] Updates commit hash of paywall-preview-resources",
      body: "This is an automatic update of the commit hash of paywall-preview-resources.",
      labels: ["pr:other"],
      team_reviewers: ["coresdk", "monetization"]
    )
  end

  desc "Records Paywall template screenshots once and distributes them to the provided target_repository_path and/or Emerge"
  lane :record_and_upload_v2_snapshots do |options|
    provided_target_repository_path = options[:target_repository_path].to_s.strip
    upload_to_emerge = options[:upload_to_emerge].to_s == "true"
    UI.user_error!("At least one of target_repository_path or upload_to_emerge must be provided.") if provided_target_repository_path.empty? && !upload_to_emerge

    this_commit_hash = sh("git", "rev-parse", "HEAD").strip

    target_repository_path = nil
    target_repository_name = nil
    target_repository_branch = "update-ios-template-screenshots"

    unless provided_target_repository_path.empty?
      target_repository_path = Dir.chdir("..") do
        File.expand_path(provided_target_repository_path)
      end

      # Update the target repository and create a new branch
      Dir.chdir(target_repository_path) do
        UI.user_error!("Target repository (#{target_repository_path}) is not clean.") unless sh("git", "status", "--porcelain").empty?
        sh("git", "fetch")
        sh("git", "checkout", "main")
        sh("git", "pull")

        run_plugin_action_in_current_directory do
          create_or_checkout_branch(branch_name: target_repository_branch)
        end
        target_repository_name = sh("basename -s .git \"$(git config --get remote.origin.url)\"").strip
      end
      UI.user_error!("Failed to determine target repository name") if target_repository_name.nil? || target_repository_name.empty?
    end

    # Loop over all platforms and generate screenshots for each, recording once and distributing
    # the resulting images to the requested destinations.
    produced_offering_ids_by_platform = {}
    paywall_template_screenshot_platforms.each do |platform|
      UI.message("Generating screenshots for platform: #{platform}")

      output_dir = record_paywall_screenshots(platform: platform)
      UI.message("Output directory for #{platform}: #{output_dir}")

      if target_repository_path
        # Copy the screenshots into the paywall-rendering-validation repo.
        produced_offering_ids_by_platform[platform] = process_paywall_screenshots(
          output_dir: output_dir,
          target_repository_path: target_repository_path,
          platform: platform
        )
      end

      upload_paywall_template_screenshots_to_emerge(platform: platform, output_dir: output_dir) if upload_to_emerge
    end

    if target_repository_path
      remove_stale_template_screenshots(
        target_repository_path: target_repository_path,
        produced_offering_ids_by_platform: produced_offering_ids_by_platform
      )

      # Commit, push, and create a PR in the target repository
      Dir.chdir(target_repository_path) do
        commit_push_and_create_pr_if_necessary(
          repo: target_repository_name,
          branch_name: target_repository_branch,
          commit_message: "[Automated] Updates template screenshots for all Apple platforms (commit: #{this_commit_hash})",
          title: "[AUTOMATIC] Updates template screenshots for all Apple platforms",
          body: "This is an automatic update of the template screenshots for iOS, iPhone app on Mac, iPad app on Mac, Mac Catalyst optimized for Mac, and Mac Catalyst scaled to match iPad platforms.",
          team_reviewers: ["coresdk", "monetization"]
        )
      end
    end
  end

  private_lane :upload_paywall_template_screenshots_to_emerge do |options|
    platform = options[:platform] || UI.user_error!("No platform provided")
    output_dir = options[:output_dir] || UI.user_error!("No output_dir provided")

    upload_snapshots_to_emerge(
      name: "Paywall Rendering Validation (#{platform})",
      id: "com.revenuecat.paywall-rendering-validation.#{platform}",
      images_path: "#{output_dir}/images"
    )
  end

  # Apple platforms we render Paywalls V2 template screenshots for.
  private_lane :paywall_template_screenshot_platforms do
    ["ios",
     # These platforms work locally but we have been unable to figure out how to get them to
     # work on CI due to codesigning issues.
     #  "iphone-app-on-mac",
     #  "ipad-app-on-mac",
     "mac-catalyst-optimized-for-mac",
     "mac-catalyst-scaled-to-match-ipad",
     "mac-native"]
  end

  private_lane :process_paywall_screenshots do |options|
    output_dir = options[:output_dir] || UI.user_error!("No output_dir provided")
    target_repository_path = options[:target_repository_path] || UI.user_error!("No target_repository_path provided")
    platform = options[:platform] || UI.user_error!("No platform provided")

    produced_offering_ids = []
    Dir.glob("#{output_dir}/images/*.png").each do |file_path|
      offering_id = File.basename(file_path, File.extname(file_path))
      produced_offering_ids << offering_id
      target_dir = "#{target_repository_path}/screenshots/templates/#{offering_id}"

      target_path = "#{target_dir}/#{platform}.png"

      UI.message("Moving #{file_path} to #{target_path}")

      FileUtils.mkdir_p(target_dir)
      FileUtils.cp(file_path, target_path)
    end

    # Return the offering IDs produced for this platform.
    produced_offering_ids
  end

  # Removes screenshots (and now-empty directories) for paywalls that were not rendered in this run. Only the
  # platform files produced by this lane are pruned (ios.png and the mac-*.png variants), each against the
  # offering IDs produced for that platform, so screenshots owned by other platforms (e.g. android.png) are
  # never affected.
  private_lane :remove_stale_template_screenshots do |options|
    target_repository_path = options[:target_repository_path] || UI.user_error!("No target_repository_path provided")
    produced_offering_ids_by_platform = options[:produced_offering_ids_by_platform] || UI.user_error!("No produced_offering_ids_by_platform provided")

    templates_dir = "#{target_repository_path}/screenshots/templates"
    produced_offering_ids_by_platform.each do |platform, produced_offering_ids|
      if produced_offering_ids.empty?
        UI.important("No #{platform} screenshots were produced; skipping stale cleanup for it.")
        next
      end

      Dir.glob("#{templates_dir}/*/#{platform}.png").each do |existing|
        offering_id = File.basename(File.dirname(existing))
        next if produced_offering_ids.include?(offering_id)
        UI.message("Removing stale #{platform} screenshot: #{existing}")
        FileUtils.rm(existing)
      end
    end

    # Remove any empty directories.
    Dir.glob("#{templates_dir}/*").each do |dir|
      next unless File.directory?(dir)
      Dir.rmdir(dir) if Dir.empty?(dir)
    end
  end

  private_lane :commit_push_and_create_pr_if_necessary do |options|
    branch_name = options[:branch_name] || UI.user_error!("No branch_name provided")
    commit_message = options[:commit_message] || UI.user_error!("No commit_message provided")
    title = options[:title] || commit_message
    body = options[:body] || commit_message
    repo = options[:repo] || REPO_NAME
    labels = options[:labels] || []
    team_reviewers = options[:team_reviewers] || []

    has_changes = !sh("git", "status", "--porcelain").empty?
    if has_changes
      commit_and_push(
        branch_name: branch_name,
        commit_message: commit_message
      )

      run_plugin_action_in_current_directory do
        create_pr_if_necessary(
          github_pr_token: ENV["GITHUB_TOKEN"],
          repo_name: repo,
          base_branch: "main",
          head_branch: branch_name,
          title: title,
          body: body,
          labels: labels,
          team_reviewers: team_reviewers
        )
      end
    else
      UI.message("No changes detected. Skipping commit, push, and PR creation.")
    end
  end

  private_lane :commit_and_push do |options|
    branch_name = options[:branch_name] || UI.user_error!("No branch_name provided")
    commit_message = options[:commit_message] || UI.user_error!("No commit_message provided")

    # Check if there are any changes to commit
    if sh("git", "status", "--porcelain").empty?
      UI.message("No changes to commit")
    else
      # Commit changes
      if File.basename(Dir.pwd) == "fastlane"
        Dir.chdir("..") do
          sh("git", "add", ".")
          sh("git", "commit", "-m", commit_message)
          sh("git", "push", "-u", "origin", branch_name)
        end
      else
        sh("git", "add", ".")
        sh("git", "commit", "-m", commit_message)
        sh("git", "push", "-u", "origin", branch_name)
      end
    end
  end

  desc "Records screenshots for paywalls"
  lane :record_paywall_screenshots do |options|
    platform = options[:platform] || UI.user_error!("No platform provided. Use one of: ios, iphone-app-on-mac, ipad-app-on-mac, mac-catalyst-optimized-for-mac, mac-catalyst-scaled-to-match-ipad")

    # Map platform to destination
    destinations = {
      "ios" => "platform=iOS Simulator,name=iPhone 16",
      "iphone-app-on-mac" => "platform=macOS,arch=arm64,variant=Designed for iPhone",
      "ipad-app-on-mac" => "platform=macOS,arch=arm64,variant=Designed for iPad",
      "mac-catalyst-optimized-for-mac" => "platform=macOS,arch=arm64,variant=Mac Catalyst",
      "mac-catalyst-scaled-to-match-ipad" => "platform=macOS,arch=arm64,variant=Mac Catalyst",
      "mac-native" => "platform=macOS,arch=arm64"
    }

    # Map platform to TARGETED_DEVICE_FAMILY build setting. This overrides the setting in the Xcode Project,
    # if we ever change the list of supported platforms we'll need to make sure to update this as well.
    targeted_device_families = {
      "mac-catalyst-optimized-for-mac" => "1,2,3,4,6",
      "mac-catalyst-scaled-to-match-ipad" => "1,2,3,4"
    }

    destination = destinations[platform]
    UI.user_error!("Invalid platform '#{platform}'. Use one of: #{destinations.keys.join(', ')}") unless destination

    output_dir = File.absolute_path("../fastlane/test_output/xctest/paywall-screenshots")
    FileUtils.rm_rf(output_dir)
    FileUtils.mkdir_p(output_dir)

    result_bundle_path = "#{output_dir}/RevenueCatUITests.xcresult"

    xcargs = []
    if targeted_device_families[platform]
      xcargs += ["TARGETED_DEVICE_FAMILY='#{targeted_device_families[platform]}'"]
    end

    Dir.chdir("../Tests/UnitTestsHostApp/fastlane") do
      match(type: "development", readonly: true)
    end

    scan_options = {
      workspace: 'RevenueCat-Tuist.xcworkspace',
      scheme: 'PaywallScreenshotTests',
      testplan: 'Paywall-Screenshots',
      destination: destination,
      result_bundle: true,
      result_bundle_path: result_bundle_path,
    }

    # Add xcargs only if we have build settings to apply
    scan_options[:xcargs] = xcargs.join(' ') unless xcargs.empty?

    scan(scan_options)

    image_output_dir = "#{output_dir}/images"
    FileUtils.mkdir_p(image_output_dir)
    extract_xcresult_images(
      xcresult_path: result_bundle_path,
      output_dir: image_output_dir
    )

    output_dir
  end

  desc "Build the Paywalls Test app for Emerge Snapshots"
  lane :build_paywalls_tester_for_emerge do |options|
    build_paywalls_tester_for_emerge_ios(options)
    # November 2025: Emerge snapshots for these platforms are flaky, disabling for now.
    # build_paywalls_tester_for_emerge_mac(platform: "mac-catalyst-scaled-to-match-ipad")
    # build_paywalls_tester_for_emerge_mac(platform: "mac-native")
    # build_paywalls_tester_for_emerge_mac(platform: "mac-catalyst-optimized-for-mac")


    # As of July 2025, Emerge Tools does not support iOS apps on Mac yet.
    # build_paywalls_tester_for_emerge_mac(platform: "ipad-app-on-mac")
    # build_paywalls_tester_for_emerge_mac(platform: "iphone-app-on-mac")
  end

  desc "Record V1 template snapshots and upload to Emerge"
  lane :record_and_upload_v1_snapshots do
    platform = ENV['PLATFORM'] || 'iOS Simulator'
    destination = ENV['DEVICE'] || "iPhone 17,OS=26.2"
    sdk = ENV['BUILD_SDK'] || 'iphonesimulator'
    begin
      xcodebuild(
        workspace: '.',
        scheme: 'RevenueCatUI',
        destination: "platform=" + platform + ",name=" + destination,
        sdk: sdk,
        result_bundle_path: 'fastlane/test_output/v1-snapshots.xcresult',
        report_formats: [:junit],
        report_path: 'fastlane/test_output/v1-snapshots/tests.xml',
        test: true,
        xcargs: "-testPlan CI-V1-Snapshots"
      )
    rescue => e
      snapshots_path = File.expand_path("../Tests/RevenueCatUITests/Templates/__Snapshots__")
      pngs = Dir.glob("#{snapshots_path}/**/*.png")
      if pngs.empty?
        UI.user_error!("Tests failed and no snapshots were recorded — this is likely a build or runtime error, not a snapshot recording failure: #{e.message}")
      end
      UI.important("V1 snapshot tests failed but #{pngs.count} snapshots were recorded — this is probably expected because recording mode causes assertion failures, please verify the diff in Emerge: #{e.message}")
    end

    upload_v1_snapshots_to_emerge
  end

  desc "Upload V1 template snapshots to Emerge using BYOS"
  lane :upload_v1_snapshots_to_emerge do
    snapshots_path = File.expand_path("../Tests/RevenueCatUITests/Templates/__Snapshots__")
    pngs = Dir.glob("#{snapshots_path}/**/*.png")
    UI.user_error!("No snapshot PNGs found in #{snapshots_path}. Recording may have failed.") if pngs.empty?
    UI.message("Found #{pngs.count} snapshot images to upload")

    upload_snapshots_to_emerge(
      name: "PaywallsTester V1 swift-snapshot-testing",
      id: "com.revenuecat.PaywallsTester.v1-snapshots",
      client_library: "swift-snapshot-testing",
      project_root: File.expand_path("../Tests/RevenueCatUITests/Templates")
    )
  end

  # Uploads snapshots to Emerge. Requires EMERGE_API_TOKEN to be available in the environment.
  #
  # Pass `client_library` + `project_root` to let Emerge discover snapshots from a supported
  # library (e.g. swift-snapshot-testing), or pass `images_path` to upload an arbitrary directory
  # of images.
  private_lane :upload_snapshots_to_emerge do |options|
    name = options[:name] || UI.user_error!("No name provided for the Emerge upload")
    id = options[:id] || UI.user_error!("No id provided for the Emerge upload")

    pr_number = detect_pr_number

    args = [
      "bundle", "exec", "emerge", "upload", "snapshots",
      "--name", name,
      "--id", id,
      "--repo-name", "RevenueCat/purchases-ios"
    ]
    args.push("--client-library", options[:client_library]) if options[:client_library]
    args.push("--project-root", options[:project_root]) if options[:project_root]
    args.push("--pr-number", pr_number) if pr_number
    args.push(options[:images_path]) if options[:images_path]

    sh(*args)
  end

  lane :build_paywalls_tester_for_emerge_ios do |options|
    scan(
      workspace: "Tests/TestingApps/PaywallsTester/PaywallsTester.xcworkspace",
      scheme: "PaywallsTester - Live Config",
      build_for_testing: true,
    )

    send_to_emerge(
        app_id_suffix: ""
    )
  end

  private_lane :build_paywalls_tester_for_emerge_mac do |options|
    platform = options[:platform]

    # As of July 2025, Emerge Tools has explicitly enabled Catalyst support for apps with the bundle identifiers com.revenuecat.PaywallsTester-<platform> for these 4 platform values.
    # If you change one of these values, you will need to verify that Catalyst support has been enabled for all bundle identifiers, or tell them the new bundle identiifer.
    case platform
    when "mac-catalyst-scaled-to-match-ipad"
      build_mac_catalyst_version(platform: platform, device_families: "1,2,3,4,5,7")
    when "mac-catalyst-optimized-for-mac"
      build_mac_catalyst_version(platform: platform, device_families: "1,2,3,4,5,6,7")
    when "ipad-app-on-mac"
      build_ios_app_on_mac_version(platform: platform, include_ipad: true)
    when "iphone-app-on-mac"
      build_ios_app_on_mac_version(platform: platform, include_ipad: false)
    when "mac-native"
      build_mac_native_version(platform: platform)
    else
      UI.user_error!("Invalid platform: #{platform}. Must be one of: mac-catalyst-scaled-to-match-ipad, mac-catalyst-optimized-for-mac, ipad-app-on-mac, iphone-app-on-mac")
    end
  end

  private_lane :build_mac_catalyst_version do |options|
    platform = options[:platform]
    device_families = options[:device_families]
    xcargs = "CODE_SIGN_IDENTITY='' CODE_SIGNING_REQUIRED=NO"
    xcargs << " TARGETED_DEVICE_FAMILY='#{device_families}'"

    # Remove macosx from SUPPORTED_PLATFORMS in project.pbxproj. xcodebuild will fail if macosx is in the list, even
    # if we override it in xcargs.
    project_file_path = File.absolute_path("../Tests/TestingApps/PaywallsTester/PaywallsTester.xcodeproj/project.pbxproj")
    file_content = File.read(project_file_path)
    modified_content = file_content.gsub(/SUPPORTED_PLATFORMS = "[^"]*";/, 'SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";')
    File.write(project_file_path, modified_content)

    archive_path = File.absolute_path("build/PaywallsTester-#{platform}.xcarchive")

    gym(
      workspace: "Tests/TestingApps/PaywallsTester/PaywallsTester.xcworkspace",
      scheme: "PaywallsTester - Live Config",
      configuration: "Debug",
      destination: "platform=macOS,arch=arm64,variant=Mac Catalyst",
      xcargs: xcargs,
      skip_codesigning: true,
      archive_path: archive_path,
      skip_package_pkg: true,
      skip_package_ipa: false
    )

    send_to_emerge(
        app_id_suffix: platform,
        archive_path: archive_path
    )
  ensure
    # Reset project file changes
    sh("git checkout -- ../Tests/TestingApps/PaywallsTester/PaywallsTester.xcodeproj/project.pbxproj")
  end

  private_lane :build_mac_native_version do |options|
    platform = options[:platform]
    xcargs = "CODE_SIGN_IDENTITY='' CODE_SIGNING_REQUIRED=NO"

    archive_path = File.absolute_path("build/PaywallsTester-#{platform}.xcarchive")

    gym(
      workspace: "Tests/TestingApps/PaywallsTester/PaywallsTester.xcworkspace",
      scheme: "PaywallsTester - Live Config",
      configuration: "Debug",
      destination: "platform=macOS,arch=arm64",
      xcargs: xcargs,
      skip_codesigning: true,
      archive_path: archive_path,
      skip_package_pkg: true,
      skip_package_ipa: false
    )

    send_to_emerge(
        app_id_suffix: platform,
        archive_path: archive_path
    )
  end

  private_lane :build_ios_app_on_mac_version do |options|
    platform = options[:platform]
    include_ipad = options[:include_ipad]

    # Build iOS version first with appropriate device family targeting
    device_families = include_ipad ? "1,2" : "1"
    xcargs = "CODE_SIGN_IDENTITY='' CODE_SIGNING_REQUIRED=NO"
    xcargs << " TARGETED_DEVICE_FAMILY='#{device_families}'"

    ios_archive_path = File.absolute_path("build/PaywallsTester-#{platform}-ios.xcarchive")

    # Build the iOS version
    gym(
      workspace: "Tests/TestingApps/PaywallsTester/PaywallsTester.xcworkspace",
      scheme: "PaywallsTester - Live Config",
      configuration: "Debug",
      destination: "generic/platform=iOS",
      xcargs: xcargs,
      archive_path: ios_archive_path,
      skip_codesigning: true,
      skip_package_pkg: true,
      skip_package_ipa: true
    )

    # Extract the .app file from the archive
    ios_app_path = "#{ios_archive_path}/Products/Applications/PaywallsTester.app"

    unless File.exist?(ios_app_path)
      UI.user_error!("Could not find iOS .app at expected path: #{ios_app_path}")
    end

    # Create the Mac app wrapper structure
    mac_app_path = File.absolute_path("build/PaywallsTester-#{platform}.app")
    wrapper_dir = "#{mac_app_path}/Wrapper"
    wrapped_app_path = "#{wrapper_dir}/PaywallsTester.app"
    symlink_path = "#{mac_app_path}/WrappedBundle"

    # Clean up any existing structure
    FileUtils.rm_rf(mac_app_path) if File.exist?(mac_app_path)

    # Create the directory structure
    FileUtils.mkdir_p(wrapper_dir)

    # Copy the iOS .app to the Wrapper directory
    UI.message("Copying iOS .app from #{ios_app_path} to #{wrapped_app_path}")
    FileUtils.cp_r(ios_app_path, wrapped_app_path)

    # Create the symlink
    UI.message("Creating symlink from #{symlink_path} to Wrapper/PaywallsTester.app")
    File.symlink("Wrapper/PaywallsTester.app", symlink_path)

    # Verify the structure was created correctly
    unless File.exist?(wrapped_app_path) && File.symlink?(symlink_path)
      UI.user_error!("Failed to create proper Mac app structure")
    end

    UI.success("Successfully created Mac app structure at #{mac_app_path}")

    send_to_emerge(
        app_id_suffix: platform,
        file_path: mac_app_path
    )
  end

  private_lane :detect_pr_number do
    pr_url = ENV["CIRCLE_PULL_REQUEST"]
    branch_name = sh("git rev-parse --abbrev-ref HEAD").strip
    pr_number = nil
    # Try to parse the PR number from the pr_url variable first
    if pr_url && pr_url.include?("/")
      pr_number = pr_url.split("/").last
    # Try to parse the PR number from the merge queue branch name. In this case,
    # there's no CIRCLE_PULL_REQUEST environment variable.
    elsif branch_name =~ /^gh-readonly-queue\/[^\/]+\/pr-([0-9]+)-[0-9a-f]+$/
      pr_number = $1
    end
    pr_number
  end

  private_lane :pr_labels_for_api_gate do
    pr_number = detect_pr_number
    token = ENV["DANGER_GITHUB_API_TOKEN"]

    if pr_number.nil? || token.nil? || token.empty?
      UI.message("No PR context, treating the API gate as unlabelled")
      []
    else
      response = github_api(
        server_url: 'https://api.github.com',
        api_token: token,
        http_method: 'GET',
        path: "/repos/RevenueCat/#{REPO_NAME}/pulls/#{pr_number}"
      )
      (response[:json]["labels"] || []).map { |label| label["name"] }
    end
  end

  private_lane :send_to_emerge do |options|
    pr_number = detect_pr_number

    emerge_args = {repo_name: "RevenueCat/purchases-ios"}
    emerge_args[:pr_number] = pr_number if pr_number
    emerge_args[:app_id_suffix] = options[:app_id_suffix]

    # Add emerge_config.yaml to .xcarchive files
    if options[:archive_path] && options[:archive_path].end_with?('.xcarchive')
      emerge_config_source = File.absolute_path("../emerge/emerge_config.yaml")
      emerge_config_dest = "#{options[:archive_path]}/emerge_config.yaml"

      if File.exist?(emerge_config_source)
        FileUtils.cp(emerge_config_source, emerge_config_dest)
        UI.message("Added emerge_config.yaml to #{File.basename(options[:archive_path])}")
      else
        UI.important("emerge_config.yaml not found at #{emerge_config_source}")
      end

      emerge_args[:file_path] = options[:archive_path]
    elsif options[:archive_path] || options[:file_path]
      emerge_args[:file_path] = options[:archive_path] || options[:file_path]
    end

    emerge(emerge_args)
  end

  desc "Runs all the iOS tests"
  lane :test_ios do |options|
    generate_snapshots = ENV["CIRCLECI_TESTS_GENERATE_SNAPSHOTS"] == "true"

    # For platforms that fail to run StoreKit tests.
    # See https://developer.apple.com/forums/thread/724068 and `FB12223404`.
    skip_sk_tests = options[:skip_sk_tests]

    # UnitTestsHostApp needs to be signed with its keychain entitlements for the KeychainTests to pass.
    Dir.chdir("../Tests/UnitTestsHostApp/fastlane") do
      match(type: "development", readonly: true)
    end

    if generate_snapshots
      if skip_sk_tests
        test_plan = "CI-RevenueCat-Snapshots"
      else
        test_plan = "CI-Snapshots"
      end
    elsif skip_sk_tests
      test_plan = "CI-RevenueCat"
    else
      test_plan = "CI-AllTests"
    end

    scan_options = {
      step_name: "scan - iPhone",
      device: ENV['SCAN_DEVICE'] || "iPhone 12 (15.2)",
      ensure_devices_found: true,
      scheme: "RevenueCat",
      prelaunch_simulator: true,
      output_types: 'junit',
      result_bundle: true,
      testplan: test_plan,
      configuration: 'Debug',
      output_directory: "fastlane/test_output/xctest/ios",
      number_of_retries: generate_snapshots ? 0 : 5, # Number of retries in an xcodebuild run
      fail_build: !generate_snapshots
    }

    if ENV["RC_STOREKIT_TEST_PREFLIGHT"] == "1" && !skip_sk_tests && !generate_snapshots
      # iOS 27 can bind the first process's purchase connection to Sandbox even after
      # SKTestSession initialization. Configure StoreKitTest in a separate process so
      # the full suite starts with XcodeTest active, without bypassing real purchases.
      # Keep both runs on the same simulator and preserve its StoreKit configuration.
      scan_options.merge!(reset_simulator: false, reinstall_app: false, parallel_testing: false, only_testing: [])
      scan(scan_options.merge(
        step_name: "scan - StoreKitTest preflight",
        only_testing: ["StoreKitUnitTests/StoreKitTestSessionInitializationTests"],
        output_directory: "fastlane/test_output/xctest/ios/storekit-preflight",
        number_of_retries: 0,
        fail_build: true
      ))
    end

    scan(scan_options)
  end

  # Screen reader behavior can only be asserted against the real accessibility tree, which SwiftUI
  # builds for an assistive technology client and not under XCTest. Needs no API key: the paywall
  # under test is one of PaywallsTester's local sample paywalls.
  desc "Runs the paywall accessibility UI tests"
  lane :paywall_accessibility_ui_tests do
    scan(
      step_name: "scan - paywall accessibility UI tests",
      workspace: "RevenueCat-Tuist.xcworkspace",
      scheme: "PaywallFixturesUITests",
      device: ENV['SCAN_DEVICE'] || "iPhone 16 Pro",
      ensure_devices_found: true,
      prelaunch_simulator: true,
      output_types: 'junit',
      result_bundle: true,
      configuration: 'Debug',
      output_directory: "fastlane/test_output/paywall_accessibility",
      number_of_retries: 2
    )
  end

  desc "Runs all the tvOS tests"
  lane :test_tvos do |options|
    generate_snapshots = ENV["CIRCLECI_TESTS_GENERATE_SNAPSHOTS"] == "true"

    # UnitTestsHostApp needs to be signed with its keychain entitlements for the KeychainTests to pass.
    Dir.chdir("../Tests/UnitTestsHostApp/fastlane") do
      match(type: "development", readonly: true)
    end

    if generate_snapshots
      test_plan = "CI-RevenueCat-Snapshots"
    else
      test_plan = "CI-AllTests"
    end

    scan_options = {
      step_name: "scan - Apple TV",
      device: ENV['SCAN_DEVICE'] || "Apple TV",
      ensure_devices_found: true,
      scheme: "RevenueCat",
      prelaunch_simulator: true,
      output_types: 'junit',
      result_bundle: true,
      testplan: test_plan,
      configuration: 'Debug',
      output_directory: "fastlane/test_output/xctest/tvos",
      number_of_retries: generate_snapshots ? 0 : 5,
      fail_build: !generate_snapshots
    }

    if ENV["RC_STOREKIT_TEST_PREFLIGHT"] == "1" && !generate_snapshots
      scan_options.merge!(reset_simulator: false, reinstall_app: false, parallel_testing: false, only_testing: [])
      scan(scan_options.merge(
        step_name: "scan - StoreKitTest preflight",
        only_testing: ["StoreKitUnitTests/StoreKitTestSessionInitializationTests"],
        output_directory: "fastlane/test_output/xctest/tvos/storekit-preflight",
        number_of_retries: 0,
        fail_build: true
      ))
    end

    scan(scan_options)
  end

  desc "Runs all the watchOS tests"
  lane :test_watchos do |options|
    generate_snapshots = ENV["CIRCLECI_TESTS_GENERATE_SNAPSHOTS"] == "true"
    if generate_snapshots
       test_plan = "CI-RevenueCat-Snapshots"
    else
      test_plan = "CI-RevenueCat"
    end

    scan(
      step_name: "scan - watchOS",
      device: ENV['SCAN_DEVICE'] || "Apple Watch Series 10 (46mm)",
      ensure_devices_found: true,
      scheme: "RevenueCat",
      prelaunch_simulator: true,
      output_types: 'junit',
      result_bundle: true,
      # Host app isn't available on watchOS
      testplan: test_plan,
      configuration: 'Debug',
      output_directory: "fastlane/test_output/xctest/watchos",
      number_of_retries: generate_snapshots ? 0 : 5, # Number of retries in an xcodebuild run
      fail_build: !generate_snapshots
    )
  end

  desc "Runs all RevenueCatAdMob tests (standalone SPM package under AdapterSDKs/RevenueCatAdMob)"
  lane :test_revenuecat_admob do
    platform = ENV['PLATFORM'] || 'iOS Simulator'
    destination = ENV['DEVICE'] || "iPhone 16,OS=18.5"
    sdk = ENV['BUILD_SDK'] || 'iphonesimulator'

    xcodebuild(
      workspace: 'AdapterSDKs/RevenueCatAdMob',
      scheme: 'RevenueCatAdMob',
      destination: "platform=" + platform + ",name=" + destination,
      sdk: sdk,
      result_bundle_path: 'fastlane/test_output/revenuecat_admob.xcresult',
      report_formats: [:junit],
      report_path: 'fastlane/test_output/revenuecat_admob/tests.xml',
      test: true
    )
  end

  desc "Runs all RevenueCatAds tests (standalone SPM package under AdapterSDKs/RevenueCatAds)"
  lane :test_revenuecat_ads do
    platform = ENV['PLATFORM'] || 'iOS Simulator'
    destination = ENV['DEVICE'] || "iPhone 16,OS=18.5"
    sdk = ENV['BUILD_SDK'] || 'iphonesimulator'

    xcodebuild(
      workspace: 'AdapterSDKs/RevenueCatAds',
      scheme: 'RevenueCatAds',
      destination: "platform=" + platform + ",name=" + destination,
      sdk: sdk,
      result_bundle_path: 'fastlane/test_output/revenuecat_ads.xcresult',
      report_formats: [:junit],
      report_path: 'fastlane/test_output/revenuecat_ads/tests.xml',
      test: true
    )
  end

  desc "Runs all RevenueCatUI tests"
  lane :test_revenuecatui do |options|
    hosted = options[:hosted].to_s == "true"
    generate_snapshots = !hosted && ENV["CIRCLECI_TESTS_GENERATE_REVENUECAT_UI_SNAPSHOTS"] == "true"
    platform = ENV['PLATFORM'] || 'iOS Simulator'
    destination = ENV['DEVICE'] || "iPhone 14,OS=16.4"
    sdk = ENV['BUILD_SDK'] || 'iphonesimulator'
    test_plan = generate_snapshots ? "CI-Snapshots" : "CI-RevenueCatUI"
    xcargs = if hosted
      "-only-testing:RevenueCatUITests/NativePaywallCloseButtonTests " \
        "-only-testing:RevenueCatUITests/NativePaywallUIKitTests " \
        "-only-testing:RevenueCatUITests/NativePaywallWorkflowTests " \
        "-only-testing:RevenueCatUITests/ButtonComponentViewTests"
    else
      "-testPlan #{test_plan}"
    end
    output_name = hosted ? 'revenuecatui-hosted' : 'revenuecatui'
    # Xcode 27 can hang in `simctl diagnose` after all tests pass, so jobs running on it
    # opt out of diagnostics collection. watchOS has always needed this.
    collect_test_diagnostics = ENV['RC_COLLECT_TEST_DIAGNOSTICS'] ||
                               (sdk == 'watchsimulator' ? 'never' : nil)
    xcargs += " -collect-test-diagnostics #{collect_test_diagnostics}" if collect_test_diagnostics
    begin
      # See https://forums.swift.org/t/i-made-a-little-spm-package-that-uses-uikit-how-can-i-run-tests-from-command-line/50399/2
      # Use xcodebuild because scan doesn't properly detect the SPM scheme. The hosted
      # pass uses the existing Tuist test target to exercise real modal presentations.
      xcodebuild(
        workspace: hosted ? 'RevenueCat-Tuist.xcworkspace' : '.',
        scheme: hosted ? 'RevenueCatUITests' : 'RevenueCatUI',
        destination: platform == 'macOS' ? "platform=macOS" : "platform=" + platform + ",name=" + destination,
        sdk: sdk,
        result_bundle_path: "fastlane/test_output/#{output_name}.xcresult",
        report_formats: [:junit],
        report_path: "fastlane/test_output/#{output_name}/tests.xml",
        test: true,
        xcargs: xcargs
      )
    rescue => e
      # Equivalent to `fail_build: !generate_snapshots`
      raise e if !generate_snapshots
    end
  end

  desc "Runs PurchasesUIService integration tests against a Release, symbol-stripped RevenueCatUI build"
  lane :test_purchases_ui_service_stripped do
    platform = ENV['PLATFORM'] || 'iOS Simulator'
    destination = ENV['DEVICE'] || "iPhone 16,OS=18.5"
    sdk = ENV['BUILD_SDK'] || 'iphonesimulator'

    xcodebuild(
      workspace: '.',
      scheme: 'RevenueCatUI-Stripped',
      destination: "platform=" + platform + ",name=" + destination,
      sdk: sdk,
      configuration: 'Release',
      result_bundle_path: 'fastlane/test_output/purchases_ui_service_stripped.xcresult',
      report_formats: [:junit],
      report_path: 'fastlane/test_output/purchases_ui_service_stripped/tests.xml',
      test: true,
      xcargs: "DEAD_CODE_STRIPPING=YES STRIP_SWIFT_SYMBOLS=YES ONLY_ACTIVE_ARCH=YES"
    )
  end

  desc "Release checks"
  lane :release_checks do |options|
    version_number = current_version_number
    check_no_git_tag_exists(version_number)
    check_pods
    export_xcframeworks
    check_no_github_release_exists(version_number)
    xcframework_installation_tests
  end

  desc "build tvOS, watchOS, macOS and visionOS"
  lane :build_tv_watch_mac_visionos do |options|
    load_spm_dependencies
    build_release(platform: 'watchOS')
    build_release(platform: 'tvOS')
    build_release(platform: 'macOS')
    build_release(platform: 'visionOS')
    build_release(platform: 'visionOS', scheme: 'RevenueCatUI')
  end

  desc "Build a scheme with app-extension-safe API checks enabled"
  lane :build_with_app_extension_safe_apis do |options|
    scheme = options[:scheme] || UI.user_error!("Missing required option: scheme")

    xcodebuild(
      workspace: "RevenueCat.xcworkspace",
      scheme: scheme,
      destination: "generic/platform=iOS",
      sdk: "iphoneos",
      configuration: "Release",
      xcargs: [
        'CODE_SIGN_IDENTITY=""',
        "CODE_SIGNING_REQUIRED=NO",
        "ONLY_ACTIVE_ARCH=YES",
        "APPLICATION_EXTENSION_API_ONLY=YES"
      ].join(" ")
    )
  end

  desc "macOS build"
  lane :build_mac do |options|
    load_spm_dependencies
    build_release(platform: 'macOS')
  end

  desc "Build BinarySizeTest app and upload it for size analysis"
  lane :build_and_upload_binary_size_analysis do |options|
    integration_method = options[:integration_method]
    unless integration_method
      UI.user_error!("Missing required option: integration_method. Expected one of: LOCAL_SOURCE, COCOAPODS, SPM")
    end

    build = build_binary_size_test_app(
      integration_method: integration_method
    )

    send_to_emerge(
      archive_path: build[:archive_path]
    )

    send_to_sentry(
      ipa_path: build[:ipa_path]
    )
  end

  private_lane :send_to_sentry do |options|
    ipa_path = options[:ipa_path]
    unless ipa_path && File.exist?(ipa_path)
      UI.user_error!("Missing thinned IPA for Sentry upload at '#{ipa_path}'")
    end

    sentry_args = sentry_ci_metadata(
      org_slug: SENTRY_ORG_SLUG,
      project_slug: SENTRY_PROJECT_SLUG
    ).merge(
      ipa_path: ipa_path,
      build_configuration: BINARY_SIZE_ANALYSIS_CONFIGURATION
    )

    sentry_upload_build(sentry_args)
  end

  private_lane :build_binary_size_test_app do |options|
    integration_method = options[:integration_method]
    unless integration_method
      UI.user_error!("Missing required option: integration_method. Expected one of: LOCAL_SOURCE, COCOAPODS, SPM")
    end

    settings_by_method = {
      "LOCAL_SOURCE" => {
        bundle_id: "com.revenuecat.binary-size-test.local-source",
        workspace_relative_path: "RevenueCat-Tuist.xcworkspace"
      },
      "COCOAPODS" => {
        bundle_id: "com.revenuecat.binary-size-test.cocoapods",
        # Created by running `pod install` inside `Projects/BinarySizeTest/`
        workspace_relative_path: "Projects/BinarySizeTest/BinarySizeTest.xcworkspace"
      },
      "SPM" => {
        bundle_id: "com.revenuecat.binary-size-test.spm",
        workspace_relative_path: "RevenueCat-Tuist.xcworkspace"
      }
    }

    settings = settings_by_method[integration_method]
    unless settings
      UI.user_error!(
        "Invalid integration_method '#{integration_method}'. " \
        "Expected one of: #{settings_by_method.keys.join(', ')}"
      )
    end

    project_root = File.expand_path("#{File.dirname(__FILE__)}/..")
    workspace_path = File.join(project_root, settings[:workspace_relative_path])

    if integration_method == "COCOAPODS"
      podfile_dir = File.join(project_root, "Projects/BinarySizeTest")
      UI.message("Integration method is COCOAPODS; running pod install in #{podfile_dir}")
      Dir.chdir(podfile_dir) do
        sh("bundle", "exec", "pod", "install")
      end
    end

    unless File.directory?(workspace_path)
      UI.user_error!(
        "Workspace not found at #{workspace_path}. " \
        "Expected to be generated by CI#{integration_method == "COCOAPODS" ? " (and pods installed)" : ""}."
      )
    end

    bundle_id = settings[:bundle_id]
    provisioning_profile_name = "match AdHoc #{bundle_id}"

    match(
      type: "adhoc",
      readonly: true,
      platform: "ios",
      app_identifier: bundle_id,
      git_url: ENV['CERTS_REPO_URL']
    )

    archive_path = File.join(project_root, "build/BinarySizeTest-#{integration_method}.xcarchive")
    output_directory = File.join(project_root, "build")

    # Build, archive, and export IPA in one step
    gym(
      workspace: workspace_path,
      scheme: "BinarySizeTest",
      configuration: BINARY_SIZE_ANALYSIS_CONFIGURATION,
      destination: "generic/platform=iOS",
      archive_path: archive_path,
      export_method: "ad-hoc",
      export_options: {
        signingStyle: "manual",
        stripSwiftSymbols: false,
        thinning: BINARY_SIZE_ANALYSIS_DEVICE,
        provisioningProfiles: {
          bundle_id => provisioning_profile_name
        }
      },
      output_directory: output_directory,
      output_name: "BinarySizeTest-#{integration_method}-#{BINARY_SIZE_ANALYSIS_DEVICE}",
      include_bitcode: false,
      include_symbols: true,
      clean: false
    )

    ipa_path = lane_context[SharedValues::IPA_OUTPUT_PATH]
    unless ipa_path && File.exist?(ipa_path)
      UI.user_error!("IPA export completed but no IPA file found in lane context")
    end

    {
      archive_path: archive_path,
      ipa_path: ipa_path
    }
  end

  desc "archive"
  lane :archive do
    match(type: "appstore", readonly: true)
    gym(export_method: "app-store")
  end

  desc "archive all platforms"
  lane :archive_all_platforms do
    platforms = {
      'ios' => { destination: 'generic/platform=ios' },
      'tvos' => { destination: 'generic/platform=tvos' },
      'catalyst' => { destination: 'generic/platform=macOS,variant=Mac Catalyst', catalyst_platform: 'macos' }
    }

    platforms.each do |platform, config|
      match_options = { type: "appstore", platform: platform, readonly: true }
      match_options[:additional_cert_types] = "mac_installer_distribution" if config[:catalyst_platform] == "macos"
      match(**match_options)

      gym_options = { export_method: "app-store", destination: config[:destination] }
      gym_options[:catalyst_platform] = config[:catalyst_platform] if config[:catalyst_platform]
      gym(**gym_options)
    end
  end

  desc "run API Tests"
  lane :run_api_tests do
    # note: it's called "run" tests but these tests only need to build to run
    workspace_path = 'RevenueCat-Tuist.xcworkspace'
    unless File.directory?(workspace_path)
      UI.important("#{workspace_path} not found. Run `tuist generate --no-open` before invoking this lane.")
    end
    xcodebuild(
      workspace: workspace_path,
      scheme: 'All API Tests',
      destination: 'generic/platform=iOS Simulator',
      configuration: 'release',
    )
  end

  desc "Setup XCConfig file for E2E tests"
  private_lane :setup_xcconfig do |options|
    api_key = options[:api_key] || UI.user_error!("api_key is required")
    force_server_error_strategy = options[:force_server_error_strategy] || "never"
    api_host = options[:api_host] || ""
    proxy_url_scheme = options[:proxy_url_scheme] || ""
    proxy_url_host = options[:proxy_url_host] || ""

    base_path = "#{Dir.pwd}/.."
    xcconfig_path = "#{base_path}/CI.xcconfig"

    UI.message("Creating XCConfig file at #{xcconfig_path}")

    content = [
      "// Auto-generated. Do not commit to VCS",
      "// Generated on #{Time.now}",
      "",
      "REVENUECAT_API_KEY = #{api_key}",
      "REVENUECAT_FORCE_SERVER_ERROR_STRATEGY = #{force_server_error_strategy}"
    ]

    content << "REVENUECAT_API_HOST = #{api_host}" unless api_host.empty?
    content << "REVENUECAT_PROXY_URL_SCHEME = #{proxy_url_scheme}" unless proxy_url_scheme.empty?
    content << "REVENUECAT_PROXY_URL_HOST = #{proxy_url_host}" unless proxy_url_host.empty?

    File.write(xcconfig_path, content.join("\n"))
    UI.success("✅ Created CI.xcconfig")
  end

  # Runs Maestro E2E tests.
  # Parameters:
  #   - backend_environment: ["production", "load_shedder_us_east_1", "load_shedder_us_east_2", "fallback"]
  #   - store: ["app_store", "test_store"]
  #
  # Note that currently the "test_store" store is only available in the production environment.
  desc "run_maestro_e2e_tests"
  lane :run_maestro_e2e_tests_ios do |options|
    backend_environment = options[:backend_environment] || "production"
    store = options[:store] || "app_store"
    output_dir = options[:output_dir] || "test_output/maestro"
    # Which subdirectory of Projects/AutomatedSDKTestsApp/maestro to run flows from (e.g. "e2e_tests", "workflows").
    flow_subdir = options[:flow_subdir] || "e2e_tests"
    skip_slack_notification = options.fetch(:skip_slack_notification, true)
    base_path = "#{Dir.pwd}/.."

    UI.message("Running Maestro E2E tests for backend environment: #{backend_environment}")

    # Get API key from environment variable
    api_key = ENV["REVENUECAT_API_KEY"]
    UI.user_error!("REVENUECAT_API_KEY environment variable must be set") unless api_key

    # Determine configuration based on backend environment
    api_host = ""
    force_server_error_strategy = "never"
    entitlement_verification_result = ""
    customer_info_source = ""
    heartbeat_monitor_ping_url = ""

    case backend_environment
    when "load_shedder_us_east_1"
      api_host = "fortress-us-east-1.revenuecat.com"
      entitlement_verification_result = "verified"
      customer_info_source = "load_shedder"
      heartbeat_monitor_ping_url = ENV["HEARTBEAT_MONITOR_URL_E2E_TESTS_LOAD_SHEDDER_US_EAST_1"]
    when "load_shedder_us_east_2"
      api_host = "fortress-us-east-2.revenuecat.com"
      entitlement_verification_result = "verified"
      customer_info_source = "load_shedder"
      heartbeat_monitor_ping_url = ENV["HEARTBEAT_MONITOR_URL_E2E_TESTS_LOAD_SHEDDER_US_EAST_2"]
    when "fallback"
      force_server_error_strategy = "primary_backend_down"
      entitlement_verification_result = "verified_on_device"
      customer_info_source = "offline_entitlements"
      heartbeat_monitor_ping_url = ENV["HEARTBEAT_MONITOR_URL_E2E_TESTS_FALLBACK"]
    when "production"
      entitlement_verification_result = "verified"
      customer_info_source = "main"
      heartbeat_monitor_ping_url = ENV["HEARTBEAT_MONITOR_URL_E2E_TESTS_PRODUCTION"]
    else
      UI.user_error!("Invalid backend_environment: #{backend_environment}. Must be one of: production, load_shedder_us_east_1, load_shedder_us_east_2, fallback")
    end

    # Set environment variables for Maestro
    environment_name = "#{backend_environment}_#{store}"
    ENV["MAESTRO_EXPECTED_VERIFICATION_RESULT"] = entitlement_verification_result
    ENV["MAESTRO_EXPECTED_CUSTOMER_INFO_SOURCE"] = customer_info_source
    ENV["MAESTRO_STORE"] = store

    UI.message("Setting Maestro environment variables:")
    UI.message("  MAESTRO_EXPECTED_VERIFICATION_RESULT: #{entitlement_verification_result}")
    UI.message("  MAESTRO_EXPECTED_CUSTOMER_INFO_SOURCE: #{customer_info_source}")

    # Setup XCConfig with the appropriate configuration
    setup_xcconfig(
      api_key: api_key,
      api_host: api_host,
      force_server_error_strategy: force_server_error_strategy
    )

    # note: it's called "run" tests but these tests only need to build to run
    xcworkspace = 'RevenueCat-Tuist.xcworkspace'
    unless File.directory?(xcworkspace)
      UI.important("#{xcworkspace} not found. Run `tuist generate --no-open` before invoking this lane.")
    end

    xcodebuild(
      workspace: xcworkspace,
      scheme: "AutomatedSDKTestsApp",
      sdk: "iphonesimulator",
      configuration: "Debug",
      derivedDataPath: "./build"
    )

    # Install app to simulator
    UI.message("Installing app to simulator...")
    sh("xcrun", "simctl", "install", "booted", "#{base_path}/build/Build/Products/Debug-iphonesimulator/AutomatedSDKTestsApp.app")

    if store == "app_store"
      bundle_id = "com.revenuecat.maestro.ios"
      storekit_config_path = "#{base_path}/Projects/AutomatedSDKTestsApp/AutomatedSDKTestsApp/Resources/StoreKit/app787ddb07e6.storekit"

      # Start file watcher in background
      UI.message("Starting StoreKit config file watcher...")
      watcher_script = "#{base_path}/fastlane/storekit_config_file_watcher.rb"
      watcher_pid = Process.spawn(
        "ruby", watcher_script,
        bundle_id, storekit_config_path
      )

      # Give watcher time to start
      sleep 1
      UI.success("✓ StoreKit config watcher started (PID: #{watcher_pid})")
    end

    success = false
    begin
      flow_dir = "#{base_path}/Projects/AutomatedSDKTestsApp/maestro/#{flow_subdir}"

      previous_test_environment = {}
      if store == "app_store"
        platform_path = sh("xcrun", "--sdk", "iphonesimulator", "--show-sdk-platform-path", log: false).strip
        {
          "DYLD_FRAMEWORK_PATH" => "#{platform_path}/Developer/Library/Frameworks",
          "DYLD_LIBRARY_PATH" => "#{platform_path}/Developer/usr/lib"
        }.each do |key, value|
          previous, status = Open3.capture2("xcrun", "simctl", "spawn", "booted", "launchctl", "getenv", key)
          previous_test_environment[key] = status.success? ? previous.strip : nil
          sh("xcrun", "simctl", "spawn", "booted", "launchctl", "setenv", key, value)
        end
      end

      UI.message("Using flow directory: #{flow_dir}")

      run_maestro_e2e_tests(
        flow_dir: flow_dir,
        output_dir: output_dir,
        environment_name: environment_name,
        max_retries: Integer(options.fetch(:max_retries, 5))
      )
      success = true
    rescue => e
      raise e
    ensure
      previous_test_environment&.each do |key, value|
        begin
          if value
            sh("xcrun", "simctl", "spawn", "booted", "launchctl", "setenv", key, value)
          else
            sh("xcrun", "simctl", "spawn", "booted", "launchctl", "unsetenv", key)
          end
        rescue StandardError
          UI.important("Could not restore simulator environment variable #{key}; continuing cleanup.")
        end
      end
      unless skip_slack_notification
        slack_backend_integration_test_results(
          environment: backend_environment,
          success: success,
          message_binary_solo_on_failure: false
        )
      end
      ping_heartbeat_monitor(url: heartbeat_monitor_ping_url)

      if store == "app_store"
        # Always stop the watcher, even if Maestro fails
        UI.message("Stopping StoreKit config watcher...")
        Process.kill('TERM', watcher_pid) rescue nil
        Process.wait(watcher_pid) rescue nil
        UI.success("✓ StoreKit config watcher stopped")
      end
    end
  end

  # Builds the SDKUpdateTester app twice, in parallel: against the latest released SDK and against the local SDK.
  # Each app is copied to <output_dir>/<release|local>/SDKUpdateTester.app, next to a version.txt file with the
  # SDK version it was built against.
  # Parameters:
  #   - release_version: released SDK version to build against. Defaults to the latest release before the current version.
  #   - output_dir: defaults to build/sdk_update_tests, relative to the repository root.
  desc "Build the SDK update test apps"
  lane :build_sdk_update_test_apps do |options|
    base_path = File.expand_path("..", Dir.pwd)
    output_dir = File.expand_path(options[:output_dir] || "build/sdk_update_tests", base_path)
    project_dir = "#{base_path}/Projects/SDKUpdateTester"
    # The release app depends on the purchases-ios-spm mirror, which only has tags, not GitHub releases.
    release_version = options[:release_version] || get_latest_release_before_version(
      repo_name: "purchases-ios",
      version: current_version_number
    )

    api_key = ENV["REVENUECAT_API_KEY"]
    UI.user_error!("REVENUECAT_API_KEY environment variable must be set") unless api_key
    setup_xcconfig(api_key: api_key)

    builds = {
      "release" => {
        project: "SDKUpdateTester-Release",
        sdk_version: release_version,
        tuist_env: { "TUIST_SDK_UPDATE_TESTER_RELEASE_VERSION" => release_version }
      },
      "local" => {
        project: "SDKUpdateTester-Local",
        sdk_version: current_version_number,
        tuist_env: {}
      }
    }
    UI.message("Building SDK update test apps with SDK versions: #{builds.transform_values { |build| build[:sdk_version] }}")

    # Generating sequentially because both projects share the same Derived folder.
    builds.each_value do |build|
      sh(build[:tuist_env], "tuist", "generate", "--path", project_dir, "--no-open")
    end

    FileUtils.rm_rf(output_dir)
    build_processes = builds.map do |name, build|
      FileUtils.mkdir_p("#{output_dir}/#{name}")
      log_path = "#{output_dir}/#{name}/xcodebuild.log"
      UI.message("Building #{name} app. Logs: #{log_path}")
      pid = Process.spawn(
        "xcodebuild", "build",
        "-project", "#{project_dir}/#{build[:project]}.xcodeproj",
        "-scheme", "SDKUpdateTester",
        "-configuration", "Debug",
        "-destination", "generic/platform=iOS Simulator",
        "-derivedDataPath", "#{base_path}/build/sdk_update_tests_derived_data/#{name}",
        "ARCHS=arm64",
        out: log_path,
        err: [:child, :out]
      )
      [name, pid, log_path]
    end

    failed_builds = build_processes.reject do |name, pid, log_path|
      _, status = Process.wait2(pid)
      UI.message("Finished building #{name} app (#{status.success? ? 'succeeded' : 'failed'})")
      status.success?
    end
    failed_builds.each do |name, _, log_path|
      UI.error("Build of #{name} app failed. Last lines of #{log_path}:")
      UI.error(File.readlines(log_path).last(50).join)
    end
    UI.user_error!("Failed to build SDK update test apps: #{failed_builds.map(&:first).join(', ')}") unless failed_builds.empty?

    builds.each do |name, build|
      app_path = "#{base_path}/build/sdk_update_tests_derived_data/#{name}/Build/Products/Debug-iphonesimulator/SDKUpdateTester.app"
      FileUtils.cp_r(app_path, "#{output_dir}/#{name}/")
      File.write("#{output_dir}/#{name}/version.txt", build[:sdk_version])
    end
    UI.success("✅ SDK update test apps available in #{output_dir}")
  end

  # Runs an SDK update test case: installs the app built against the released SDK and runs the test case's
  # before_update.yaml flow, then installs the app built against the local SDK over it (keeping its data, like
  # an app update) and runs the after_update.yaml flow.
  # Parameters:
  #   - test_case: name of the flows directory in Projects/SDKUpdateTester/maestro (e.g. "anonymous_user").
  #   - apps_dir: output_dir of build_sdk_update_test_apps. Defaults to build/sdk_update_tests.
  #   - output_dir: defaults to fastlane/test_output/sdk_update_tests/<test_case>.
  #   - max_attempts: defaults to 3.
  desc "Run an SDK update Maestro test case"
  lane :run_sdk_update_test do |options|
    test_case = options[:test_case] || UI.user_error!("test_case is required")
    base_path = File.expand_path("..", Dir.pwd)
    apps_dir = File.expand_path(options[:apps_dir] || "build/sdk_update_tests", base_path)
    flows_dir = "#{base_path}/Projects/SDKUpdateTester/maestro/#{test_case}"
    app = lambda do |name|
      version_path = "#{apps_dir}/#{name}/version.txt"
      UI.user_error!("#{version_path} not found. Run build_sdk_update_test_apps first.") unless File.exist?(version_path)
      { app_path: "#{apps_dir}/#{name}/SDKUpdateTester.app", sdk_version: File.read(version_path).strip }
    end

    run_sdk_update_maestro_test(
      platform: "ios",
      app_id: "com.revenuecat.SDKUpdateTester",
      steps: [
        app.call("release").merge(flow: "#{flows_dir}/before_update.yaml"),
        app.call("local").merge(flow: "#{flows_dir}/after_update.yaml")
      ],
      output_dir: File.expand_path(options[:output_dir] || "fastlane/test_output/sdk_update_tests/#{test_case}", base_path),
      max_attempts: Integer(options.fetch(:max_attempts, 3))
    )
  end

  desc "replace API KEY for installation and integration tests"
  lane :replace_api_key_integration_tests do
    replace_text_in_files(
      previous_text: "REVENUECAT_API_KEY",
      new_text: ENV["REVENUECAT_API_KEY"],
      paths_of_files_to_update: [
        './Tests/BackendIntegrationTests/Constants.swift',
        './Tests/InstallationTests/CommonFiles/RCInstallationRunner.m'
      ]
    )

    # V3LoadShedderIntegrationTests.swift and V4LoadShedderIntegrationTests.swift are missing when
    # running Carthage installation tests because they are removed in the prepare_for_carthage lane.
    # Passing skip_missing_files: true prevents the replace_text_in_files action from failing in this case.
    replace_text_in_files(
      previous_text: "REVENUECAT_LOAD_SHEDDER_API_KEY",
      new_text: ENV["REVENUECAT_LOAD_SHEDDER_API_KEY"],
      paths_of_files_to_update: [
        './Tests/BackendIntegrationTests/Constants.swift',
        './Tests/v3LoadShedderIntegration/v3LoadShedderIntegrationTests/V3LoadShedderIntegrationTests.swift',
        './Tests/v4LoadShedderIntegration/v4LoadShedderIntegrationTests/V4LoadShedderIntegrationTests.swift'
      ],
      skip_missing_files: true
    )
    replace_text_in_files(
      previous_text: "REVENUECAT_CUSTOM_ENTITLEMENT_COMPUTATION_API_KEY",
      new_text: ENV["REVENUECAT_CUSTOM_ENTITLEMENT_COMPUTATION_API_KEY"],
      paths_of_files_to_update: [
        './Tests/BackendIntegrationTests/Constants.swift',
        './Tests/InstallationTests/SPMCustomEntitlementComputationInstallation/InstallationTests.swift'
      ]
    )

    replace_text_in_files(
      previous_text: "REVENUECAT_PROXY_URL",
      new_text: ENV["REVENUECAT_PROXY_URL"].to_s,
      paths_of_files_to_update: [
        './Tests/BackendIntegrationTests/Constants.swift',
        './Tests/InstallationTests/CommonFiles/RCInstallationRunner.m',
        './Tests/InstallationTests/SPMCustomEntitlementComputationInstallation/InstallationTests.swift'
      ],
      allow_empty: true
    )

    replace_text_in_files(
      previous_text: "REVENUECAT_API_BASE_URL",
      new_text: ENV["REVENUECAT_API_BASE_URL"].to_s,
      paths_of_files_to_update: [
        './Tests/BackendIntegrationTests/Constants.swift'
      ],
      allow_empty: true
    )
  end

  desc "Create XCFramework, and create GitHub release"
  lane :release do |options|
    version_number = current_version_number
    export_xcframeworks
    github_release(version: version_number)
  end

  desc "Release to CocoaPods"
  lane :push_revenuecat_pod do |options|
    pod_push_with_error_handling(path: "RevenueCat.podspec", synchronous: true)
  end

  desc "Release to CocoaPods"
  lane :push_revenuecatui_pod do |options|
    pod_push_with_error_handling(path: "RevenueCatUI.podspec", synchronous: true)
  end

  desc "Tag current branch with current version number"
  lane :tag_current_branch do |options|
    version_number = current_version_number
    check_no_git_tag_exists(version_number)

    add_git_tag(tag: version_number)
    push_git_tags(tag: version_number)
  end

  desc "Trigger bump"
  lane :trigger_bump do
    trigger_action_in_circle_ci(action: 'bump', repo_name: REPO_NAME)
  end

  desc "Trigger bump SDK in RC mobile app"
  lane :trigger_bump_sdk_in_rc_mobile_app do
    require 'rest-client'

    version = current_version_number

    circle_token = ENV["RC_APP_CIRCLE_CI_TOKEN"]
    UI.user_error!("Please set the RC_APP_CIRCLE_CI_TOKEN environment variable") unless circle_token

    definition_id = ENV["RC_APP_CIRCLE_CI_PIPELINE_DEFINITION_ID"]
    UI.user_error!("Please set the RC_APP_CIRCLE_CI_PIPELINE_DEFINITION_ID environment variable") unless definition_id

    project_slug = ENV["RC_APP_CIRCLE_CI_PROJECT_SLUG"]
    UI.user_error!("Please set the RC_APP_CIRCLE_CI_PROJECT_SLUG environment variable") unless project_slug

    # Make request
    headers = {"Circle-Token": circle_token, "Content-Type": "application/json", "Accept": "application/json"}
    data = {
      definition_id: definition_id,
      config: { branch: "main" },
      checkout: { branch: "main" },
      parameters: {
        bump_sdk: true,
        sdk_version: version
      }
    }
    url = "https://circleci.com/api/v2/project/#{project_slug}/pipeline/run"

    begin
      RestClient.post url, data.to_json, headers
      UI.success "RC App Pipeline triggered successfully"
    rescue => e
      UI.error "Failed to trigger RC App Pipeline: #{e.message}"
      if e.respond_to?(:response) && e.response
        UI.error "Response body: #{e.response.body}"
      end
      raise e
    end
  end

  desc "Checks whether the RevenueCat-Swift.h is up to date"
  private_lane :check_autogenerated_header_up_to_date do |options|
    ensure_git_status_clean
    compile_autogenerated_header
    begin
      ensure_git_status_clean
    rescue => e
      UI.error("RevenueCat-Swift.h autogenerated file changed. Make sure it's up-to-date before releasing.")
    end
  end

  private_lane :compile_autogenerated_header_commit_and_push do |options|
    ensure_git_status_clean
    compile_autogenerated_header
    begin
      ensure_git_status_clean
    rescue => e
      commit_current_changes(commit_message: "Update RevenueCat-Swift.h for version #{current_version_number}")
      push_to_git_remote
    end
  end

  desc "Trigger CircleCI job to generate snapshots for RevenueCat"
  lane :generate_snapshots_RC do
    generate_snapshots(
      pipeline: "generate_snapshots"
    )
  end

  desc "Trigger CircleCI job to generate snapshots for RevenueCatUI"
  lane :generate_snapshots_RCUI do
    generate_snapshots(
      pipeline: "generate_revenuecatui_snapshots"
    )
  end

  desc "Trigger CircleCI job to generate and update baseline swiftinterface files"
  lane :trigger_generate_swiftinterface do
    generate_snapshots(
      pipeline: "generate_swiftinterface"
    )
  end

  private_lane :build_release do |options|
    platform = options[:platform]
    scheme = options[:scheme] || 'RevenueCat'

    xcodebuild(
      workspace: 'RevenueCat.xcworkspace',
      scheme: scheme,
      destination: "generic/platform=#{platform}",
      configuration: 'release',
      xcargs: "CODE_SIGN_IDENTITY='' CODE_SIGNING_REQUIRED=NO ONLY_ACTIVE_ARCH=YES"
    )
  end

  private_lane :generate_snapshots do |options|
    require 'rest-client'

    # Prompt branch
    default_branch = git_branch
    branch = UI.input("Branch (defaults to #{default_branch}): ")
    branch = default_branch if branch == ""

    # Get CircleCI token
    circle_token = ENV["CIRCLE_TOKEN"]
    UI.user_error! "Please set the CIRCLE_TOKEN environment variable" unless circle_token

    # Make request
    headers = {"Circle-Token": circle_token, "Content-Type": "application/json", "Accept": "application/json"}
    data = {parameters: {options[:pipeline] => true}, branch: branch}
    url = "https://circleci.com/api/v2/project/github/RevenueCat/#{REPO_NAME}/pipeline"

    resp = RestClient.post url, data.to_json, headers

    # Print workflow url
    number = JSON.parse(resp.body)["number"]
    workflow_url = "https://app.circleci.com/pipelines/github/RevenueCat/#{REPO_NAME}/#{number}"

    UI.important "Workflow: #{workflow_url}"
  end

  desc "Push updated performance baselines to the PR branch"
  lane :push_performance_baselines do
    Dir.chdir(File.expand_path('..', __dir__)) do
      PerformanceBaselineHelper.push
    end
  end

  desc "Creates a new PR after new snapshot files were generated"
  lane :create_snapshot_pr do |options|
    push_snapshot_pr(
      version: options[:version],
      repo: "revenuecat/#{REPO_NAME}",
      base_branch: ENV["CIRCLE_BRANCH"],
      files_to_add: "../*/__Snapshots__/*"
     )
  end

  desc "Creates a new PR after new swiftinterface baseline files were generated"
  lane :create_swiftinterface_pr do
    base_branch = ENV["CIRCLE_BRANCH"]
    build_number = ENV["CIRCLE_BUILD_NUM"]
    branch_name = "generated_swiftinterface/#{base_branch}-#{build_number}"

    ApiDiffHelper.copy_generated_swiftinterface_files("../api")

    file_count = sh("git diff --numstat ../api/*.swiftinterface | wc -l").strip.to_i

    if file_count == 0
      UI.important("No swiftinterface changes to commit")
    else
      sh("git", "checkout", "-b", branch_name)
      sh("git", "add", "../api/*.swiftinterface")
      sh("git", "commit", "-m", "Update baseline swiftinterface files")
      sh("git", "push", "origin", branch_name)

      circle_user = ENV["CIRCLE_USERNAME"]
      branch_link = "https://github.com/RevenueCat/purchases-ios/tree/#{base_branch}"
      body = "Requested by @#{circle_user} for [#{base_branch}](#{branch_link})"

      create_pull_request(
        repo: "revenuecat/#{REPO_NAME}",
        title: "Update baseline swiftinterface files for `#{base_branch}`",
        body: body,
        base: base_branch,
        api_token: ENV["GITHUB_TOKEN"],
        head: branch_name,
        labels: ["pr:other"]
      )
    end
  end

  desc "Generate and update baseline swiftinterface files"
  lane :update_swiftinterface_baselines do |options|
    schemes = if options[:scheme]
                [options[:scheme]]
              else
                ApiDiffHelper::MODULES
              end

    generate_swiftinterface(options)
    ApiDiffHelper.copy_generated_swiftinterface_files("../api", schemes)
  end


  private_lane :push_snapshot_pr do |options|
    version = options[:version]
    base_branch = options[:base_branch]
    build_number = ENV["CIRCLE_BUILD_NUM"]

    ## Version can be empty
    if version.empty?
      branch_name = "generated_snapshots/#{base_branch}-#{build_number}"
    else
      branch_name = "generated_snapshots/#{base_branch}-#{build_number}-#{version}"
    end

    original_branch = sh("git", "rev-parse", "--abbrev-ref", "HEAD").strip

    begin
      sh("git", "checkout", "-b", branch_name)
      sh("git", "add", options[:files_to_add])

      file_count = sh("git diff --cached --numstat | wc -l").strip.to_i

      if file_count == 0
        UI.important("No files to be committed")
      else
        sh("git", "commit", "-m", "Generating new test snapshots")
        sh("git", "push", "origin", branch_name)

        circle_user = ENV["CIRCLE_USERNAME"]
        branch_link = "https://github.com/RevenueCat/purchases-ios/tree/#{base_branch}"
        body = "Requested by @#{circle_user} for [#{base_branch}](#{branch_link})"

        create_pull_request(
          repo: options[:repo],
          title: "Generating new test snapshots for `#{base_branch}` #{version.empty? ? "" : "- #{version}"}",
          body: body,
          base: base_branch,
          api_token: ENV["GITHUB_TOKEN"],
          head: branch_name,
          labels: ["pr:other", "pr:changelog_ignore", "test"]
        )
      end
    ensure
      sh("git", "checkout", original_branch)
    end
  end

  desc "Creates RevenueCat-Swift.h for a new release"
  lane :compile_autogenerated_header do |options|
    # See https://github.com/RevenueCat/purchases-ios/blob/main/Documentation.docc/V4_API_Migration_guide.md#objc--spm

    require 'fileutils'

    arch = "x86_64"

    Dir.mktmpdir {|dir|
      xcodebuild(
        scheme: 'RevenueCat',
        workspace: 'RevenueCat.xcworkspace',
        derivedDataPath: dir,
        configuration: 'release',
        arch: arch,
        build_settings: {
          'ONLY_ACTIVE_ARCH': 'YES'
        }
      )

      FileUtils.cp(
        "#{dir}/Build/Intermediates.noindex/RevenueCat.build/Release/RevenueCat.build/Objects-normal/#{arch}/RevenueCat-Swift.h",
        File.absolute_path("../Tests/InstallationTests/CommonFiles/RevenueCat-Swift.h")
      )
    }
  end

  lane :export_xcframeworks do |options|
    create_and_sign_xcframework(
      workspace: 'RevenueCat.xcworkspace',
      scheme: 'RevenueCat',
      product_name: 'RevenueCat',
      platforms: [
      'iOS',
      'macOS',
      'maccatalyst',
      'tvOS',
      'watchOS',
      'visionOS'
    ])
    create_and_sign_xcframework(
      workspace: 'RevenueCat.xcworkspace',
      scheme: 'RevenueCatUIDev',
      product_name: 'RevenueCatUI',
      platforms: [
      'iOS',
      'maccatalyst',
      'watchOS',
    ])
  end

  private_lane :create_and_sign_xcframework do |options|
    workspace = options[:workspace]
    scheme = options[:scheme]
    product_name = options[:product_name]
    platforms = options[:platforms]

    output_directory = "build/xcframeworks/#{product_name}"

    create_xcframework(
      workspace: workspace,
      destinations: platforms,
      scheme: scheme,
      product_name: product_name,
      xcframework_output_directory: output_directory,
      # Bitcode is no longer required since Xcode 14.
      enable_bitcode: false,
      include_debug_symbols: true,
      # Set SKIP_INSTALL and BUILD_LIBRARY_FOR_DISTRIBUTION.
      # See https://developer.apple.com/documentation/xcode/creating-a-multi-platform-binary-framework-bundle#Set-up-the-project
      override_xcargs: true,
      xcargs: "SWIFT_ACTIVE_COMPILATION_CONDITIONS='$(inherited) RC_XCFRAMEWORK'"
    )

    project_root = File.expand_path("#{File.dirname(__FILE__)}/..")
    xcframework_path = File.join(project_root, output_directory, "#{product_name}.xcframework")
    xcframework_zip_path = File.join(project_root, "#{product_name}.xcframework.zip")

    # Verify xcframework was created
    unless File.directory?(xcframework_path)
      UI.user_error!("XCFramework not found at #{xcframework_path}")
    end

    # Post-process headers to suppress StoreKit 1 deprecation warnings
    suppress_deprecation_warnings_in_headers(
      xcframework_path: xcframework_path
    )

    # Sign XCFramework for Xcode 15
    sign_xcframework(
      file: xcframework_path
    )

    # Use zip -r to preserve directory structure
    # Change to the xcframework's parent directory so the zip contains the xcframework folder
    Dir.chdir(File.dirname(xcframework_path)) do
      sh("zip", "-r", "-y", "-q", xcframework_zip_path, File.basename(xcframework_path))
    end

    # Verify that the zipped xcframework passes codesign verification
    Dir.mktmpdir("xcframework-verify") do |tmpdir|
      sh("unzip", "-q", xcframework_zip_path, "-d", tmpdir)
      extracted_xcframework = File.join(tmpdir, "#{product_name}.xcframework")
      sh("codesign", "--verify", "--verbose", extracted_xcframework)
      UI.success("Codesign verification passed for #{product_name}.xcframework.zip")
    end

  end

  desc "Generate Tuist workspace"
  lane :tuist_generate do |options|
    project_root = File.expand_path("#{File.dirname(__FILE__)}/..")
    query = options[:query] || ""
    env_vars = options[:env_vars] || {}

    Dir.chdir(project_root) do
      tuist_binary = `which tuist 2>/dev/null`.strip
      if tuist_binary.empty?
        UI.user_error!("tuist not found in PATH. Install it with `mise install` or run the `revenuecat/install-mise-tools` CI step (tools: tuist).")
      end

      # Build environment variable exports for TUIST_ prefixed variables
      env_exports = []
      env_vars.each do |var_name, var_value|
        tuist_var_name = "TUIST_#{var_name}"
        env_exports << "export #{tuist_var_name}=#{var_value}"
      end

      # Build tuist command
      tuist_cmd = "#{tuist_binary} install && #{tuist_binary} generate"
      if query && !query.empty?
        tuist_cmd += " #{query}"
      end
      tuist_cmd += " --no-open"

      # Run tuist generate
      if env_exports.any?
        sh("#{env_exports.join(' && ')} && #{tuist_cmd}")
      else
        sh(tuist_cmd)
      end
    end

    # Verify workspace was generated
    workspace_path = File.join(project_root, "RevenueCat-Tuist.xcworkspace")
    unless File.directory?(workspace_path)
      UI.user_error!("Tuist workspace not found at #{workspace_path}")
    end

    UI.success("✓ Tuist workspace generated successfully")
  end

  desc "Test XCFramework integration by building XCFrameworkInstallationTests app"
  lane :xcframework_installation_tests do |options|
    project_root = File.expand_path("#{File.dirname(__FILE__)}/..")

    # Extract xcframework zip files to the root of the project
    xcframework_zips = [
      "RevenueCat.xcframework.zip",
      "RevenueCatUI.xcframework.zip"
    ]

    xcframework_zips.each do |zip_file|
      zip_path = File.join(project_root, zip_file)
      unless File.exist?(zip_path)
        UI.user_error!("XCFramework zip not found: #{zip_path}")
      end

      UI.message("Extracting #{zip_file} to project root...")
      Dir.chdir(project_root) do
        sh("unzip", "-o", "-q", zip_file)
      end

      # Verify the xcframework was extracted
      xcframework_name = zip_file.gsub(".zip", "")
      xcframework_path = File.join(project_root, xcframework_name)
      unless File.directory?(xcframework_path)
        UI.user_error!("XCFramework extraction failed: #{xcframework_path} not found")
      end
      UI.success("✓ Extracted #{xcframework_name}")
    end

    # Generate Tuist project for XCFrameworkInstallationTests
    UI.message("Generating Tuist project for XCFrameworkInstallationTests...")
    tuist_generate(env_vars: { "INCLUDE_XCFRAMEWORK_INSTALLATION_TESTS" => "true" })

    # Use the project directly instead of the workspace to ensure the build
    # uses the xcframeworks and not source targets from other workspace projects.
    project_path = File.join(project_root, "Projects", "XCFrameworkInstallationTests", "XCFrameworkInstallationTests.xcodeproj")

    unless File.directory?(project_path)
      UI.user_error!("XCFrameworkInstallationTests project not found at #{project_path}")
    end

    # RevenueCat xcframework: all platforms
    revenuecat_destinations = [
      { destination: "generic/platform=iOS Simulator", description: "iOS Simulator" },
      { destination: "generic/platform=iOS", description: "iOS Device" },
      { destination: "generic/platform=macOS,variant=Mac Catalyst", description: "Mac Catalyst" },
      { destination: "generic/platform=macOS", description: "macOS" },
      { destination: "generic/platform=watchOS Simulator", description: "watchOS Simulator" },
      { destination: "generic/platform=watchOS", description: "watchOS Device" },
      { destination: "generic/platform=visionOS Simulator", description: "visionOS Simulator" },
      { destination: "generic/platform=visionOS", description: "visionOS Device" }
    ]

    # RevenueCatUI xcframework: iOS, Mac Catalyst, watchOS only
    revenuecatui_destinations = [
      { destination: "generic/platform=iOS Simulator", description: "iOS Simulator" },
      { destination: "generic/platform=iOS", description: "iOS Device" },
      { destination: "generic/platform=macOS,variant=Mac Catalyst", description: "Mac Catalyst" },
      { destination: "generic/platform=watchOS Simulator", description: "watchOS Simulator" },
      { destination: "generic/platform=watchOS", description: "watchOS Device" }
    ]

    schemes = [
      { scheme: "XCFrameworkInstallationTests", destinations: revenuecat_destinations },
      { scheme: "XCFrameworkUIInstallationTests", destinations: revenuecatui_destinations }
    ]

    schemes.each do |scheme_info|
      scheme = scheme_info[:scheme]
      destinations = scheme_info[:destinations]

      UI.message("\nBuilding #{scheme} for #{destinations.length} destination(s)...")
      destinations.each_with_index do |dest_info, index|
        UI.message("[#{index + 1}/#{destinations.length}] Building #{scheme} for #{dest_info[:description]}...")
        xcodebuild(
          project: project_path,
          scheme: scheme,
          destination: dest_info[:destination],
          configuration: "Debug",
          derivedDataPath: File.join(project_root, "build"),
          xcargs: "CODE_SIGN_IDENTITY='' CODE_SIGNING_REQUIRED=NO CODE_SIGNING_ALLOWED=NO"
        )
        UI.success("✓ Built successfully for #{dest_info[:description]}")
      end
    end

    UI.success("\n✓ XCFramework integration test passed - all schemes built successfully for all destinations")
  end

  desc "Run BackendIntegrationTests"
  desc "Runs the remote config blob health monitor against the live backend (two projects)"
  desc "Keys come from the environment (REVENUECAT_REMOTE_CONFIG_CDN_API_KEY / _INLINE_API_KEY);"
  desc "each test skips if its key is unset, so no key is ever written into source."
  lane :remote_config_production_tests do
    scan(
      workspace: 'RevenueCat-Tuist.xcworkspace',
      scheme: "RemoteConfigProductionTests",
      device: ENV['SCAN_DEVICE'] || "iPhone 16 (18.5)",
      ensure_devices_found: true,
      derived_data_path: "scan_derived_data",
      output_types: 'junit',
      result_bundle: true,
      configuration: 'Debug',
      output_directory: "fastlane/test_output/xctest/ios",
      number_of_retries: 3
    )
  end

  lane :backend_integration_tests do |options|
    generate_snapshots = ENV["CIRCLECI_TESTS_GENERATE_SNAPSHOTS"] == "true"
    test_configuration = generate_snapshots ? "GenerateSnapshots" : "Default"
    xcargs = "-collect-test-diagnostics never"

    replace_api_key_integration_tests

    success = false
    begin
      scan_options = {
        workspace: 'RevenueCat-Tuist.xcworkspace',
        scheme: "BackendIntegrationTests",
        ensure_devices_found: true,
        derived_data_path: "scan_derived_data",
        output_types: 'junit',
        output_style: 'raw',
        result_bundle: true,
        testplan: options[:test_plan],
        xcargs: xcargs,
        only_test_configurations: [test_configuration],
        configuration: 'Debug',
        output_directory: "fastlane/test_output/xctest/ios",
        number_of_retries: generate_snapshots ? 0 : 5,
        fail_build: !generate_snapshots,
      }
      if ENV["RC_STOREKIT_TEST_PREFLIGHT"] == "1" && !generate_snapshots
        test_plan_name = options[:test_plan] || "BackendIntegrationTests-All"
        test_plan_path = File.join(__dir__, "..", "BackendIntegrationTests", "#{test_plan_name}.xctestplan")
        preflight_tests = JSON.parse(File.read(test_plan_path)).fetch("testTargets").map do |target|
          "#{target.fetch("target").fetch("name")}/BackendStoreKitPreflightTests"
        end
        scan_options.merge!(reset_simulator: false, reinstall_app: false, parallel_testing: false)
        scan(scan_options.merge(
          step_name: "scan - backend StoreKitTest preflight",
          only_testing: preflight_tests,
          xcargs: "-collect-test-diagnostics never",
          output_directory: "fastlane/test_output/xctest/ios/storekit-preflight",
          number_of_retries: 0,
          fail_build: true
        ))
      end
      scan(scan_options)
      success = true
    rescue => exception
      raise exception
    ensure
      if options[:test_plan] == "BackendIntegrationTests-All-CI"
        # In the All CI backend integration test plan the load shedder tests are ran against us-east-1 and us-east-2 and all the other tests are ran against production
        slack_backend_integration_test_results(
          environment: "production, load_shedder_us_east_1, load_shedder_us_east_2, fallback",
          success: success,
          message_binary_solo_on_failure: false,
          notify_success_only_on_recovery: true
        )
        ping_heartbeat_monitor(url: ENV['HEARTBEAT_MONITOR_URL_BACKEND_INTEGRATION_TESTS'])
      end
    end
  end

  desc "Run LoadShedder tests in old major"
  lane :loadshedder_integration_tests_in_old_major do |options|
    replace_api_key_integration_tests
    major = options[:major] || UI.user_error!("Please provide :major (Integer, e.g., 3 or 4)")
    major = major.to_i
    UI.user_error!("Unsupported major version: #{major}. Supported: 3 or 4") unless [3, 4].include?(major)
    success = false
    begin
      scan(
        project: "./Tests/v#{major}LoadShedderIntegration/v#{major}LoadShedderIntegration.xcodeproj",
        device: ENV['SCAN_DEVICE'] || "iPhone 16 (18.5)",
        scheme: "v#{major}LoadShedderIntegration",
        ensure_devices_found: true,
        derived_data_path: "scan_derived_data",
        output_types: 'junit',
        output_style: 'raw',
        result_bundle: true,
        configuration: 'Debug',
        output_directory: "fastlane/test_output/xctest/ios"
      )
      success = true
    rescue => exception
      raise exception
    ensure
      slack_backend_integration_test_results(
        environment: "load_shedder_us_east_1",
        success: success,
        version: major.to_s,
        message_binary_solo_on_failure: false,
        notify_success_only_on_recovery: true
      )
      ping_heartbeat_monitor(url: ENV["HEARTBEAT_MONITOR_URL_LOAD_SHEDDER_V#{major}_INTEGRATION_TESTS"])
    end
  end


  desc "Update swift package commit"
  lane :update_swift_package_commit do
    project_file_locations = [
      './Tests/InstallationTests/SPMInstallation/SPMInstallation.xcodeproj/project.pbxproj',
      './Tests/InstallationTests/SPMCustomEntitlementComputationInstallation/SPMInstallation.xcodeproj/project.pbxproj',
      './Tests/InstallationTests/ReceiptParserInstallation/ReceiptParserInstallation.xcodeproj/project.pbxproj',
      './Examples/MagicWeather/MagicWeather.xcodeproj/project.pbxproj',
      './Examples/MagicWeatherSwiftUI/Magic Weather SwiftUI.xcodeproj/project.pbxproj'
    ]

    old_kind_line = "kind = branch;"
    new_kind_line = "kind = revision;"

    commit_hash = last_git_commit[:commit_hash]
    old_branch_line = "branch = main;"
    new_revision_line = "revision = #{commit_hash};"

    replace_text_in_files(
      previous_text: old_kind_line,
      new_text: new_kind_line,
      paths_of_files_to_update: project_file_locations
    )

    replace_text_in_files(
      previous_text: old_branch_line,
      new_text: new_revision_line,
      paths_of_files_to_update: project_file_locations
    )
  end

  private_lane :suppress_deprecation_warnings_in_headers do |options|
    xcframework_path = options[:xcframework_path]

    # Find all RevenueCat-Swift.h files in the XCFramework
    swift_headers = Dir.glob("#{xcframework_path}/**/RevenueCat-Swift.h")

    swift_headers.each do |header_path|
      UI.message("Processing header: #{header_path}")

      content = File.read(header_path)

      # Check if pragma directives are already present
      unless content.include?('#pragma clang diagnostic ignored "-Wdeprecated-declarations"')
        # Add pragma directives to suppress StoreKit 1 deprecation warnings
        pragma_insertion_point = content.index('#pragma clang diagnostic ignored "-Wgcc-compat"')

        if pragma_insertion_point
          insertion_index = content.index("\n", pragma_insertion_point) + 1
          pragma_directives = <<~PRAGMA
#pragma clang diagnostic ignored "-Wdeprecated-declarations"
          PRAGMA

          content.insert(insertion_index, pragma_directives)
          File.write(header_path, content)
          UI.success("Added deprecation warning suppression to #{File.basename(header_path)}")
        else
          UI.error("Could not find insertion point in #{File.basename(header_path)}")
        end
      else
        UI.message("Deprecation warnings already suppressed in #{File.basename(header_path)}")
      end
    end
  end

  private_lane :sign_xcframework do |options|
    match(
      readonly: true,
      git_url: ENV['CERTS_REPO_URL'],
      type: 'appstore',
      skip_provisioning_profiles: true,
      # This isn't important, we just need to fetch the certificate
      app_identifier: "com.revenuecat.CarthageIntegration"
    )

    sh(
      "codesign",
      "--timestamp",
      "-v",
      "--sign", "Apple Distribution: RevenueCat, Inc. (8SXR2327BM)",
      options[:file]
    )
  end

  private_lane :build_symbols_for_docs do
    ios_version = ENV['DOCS_IOS_VERSION'] || "16.1"

    # Build for iOS instead of the default macOS. This ensures that iOS-only symbols are included in the docs.
    triple = "arm64-apple-ios#{ios_version}-simulator"
    sdk_path = sh("xcrun", "--sdk", "iphonesimulator", "--show-sdk-path").strip
    platform_args = ["--triple", triple, "--sdk", sdk_path]

    sh("swift", "build", "--target", "RevenueCat", *platform_args)
    bin_path, status = Open3.capture2("swift", "build", *platform_args, "--show-bin-path")
    UI.user_error!("Could not determine the SwiftPM bin path") unless status.success?
    bin_path = bin_path.strip

    # Extracted symbol graphs include doc comments inherited from protocol requirements,
    # which symbol graphs emitted by the compiler (`-emit-symbol-graph`) don't.
    sh("xcrun", "swift-symbolgraph-extract",
       "-module-name", "RevenueCat",
       "-target", triple,
       "-sdk", sdk_path,
       # Newer SwiftPM versions put modules in `Modules/`, older ones directly in the bin path.
       "-I", File.join(bin_path, "Modules"),
       "-I", bin_path,
       "-minimum-access-level", "public",
       "-emit-extension-block-symbols",
       # Must be inside `.build`: `--additional-symbol-graph-dir .build` replaces the DocC plugin's own
       # symbol graph directory, and DocC only finds the plugin's macOS symbol graphs by searching `.build` recursively.
       "-output-dir", ".build")
  end

  desc "Preview docs"
  lane :preview_docs do
    ios_version = ENV['DOCS_IOS_VERSION'] || "16.1"

    ENV["INCLUDE_DOCC_PLUGIN"] = "true"
    Dir.chdir("..") do
      build_symbols_for_docs

      sh("swift",
         "package",
         "--disable-sandbox",
         "preview-documentation",
         "--target",
         "RevenueCat",
         "--platform",
         "name=iOS,version=#{ios_version}",
         "--transform-for-static-hosting",
         "--enable-inherited-docs",
         "--additional-symbol-graph-dir", ".build")
    end
  end

  desc "Generate docs without publishing"
  lane :build_docs do
    build_docs_shared
  end

  desc "Generate & publish docs"
  lane :build_and_publish_docs do
    docs_generation_folder = build_docs_shared

    version_number = current_version_number
    docs_repo_base_url = ENV["DOCS_REPO_BASE_URL"]
    docs_repo_name = ENV["DOCS_REPO_NAME"]

    UI.user_error!("Missing environment variable: DOCS_REPO_BASE_URL") unless docs_repo_base_url
    UI.user_error!("Missing environment variable: DOCS_REPO_NAME") unless docs_repo_name

    docs_repo_url = File.join(docs_repo_base_url, docs_repo_name)

    docs_files = Dir.glob("../scripts/docs/*.html").map { |file| File.realpath(file) }
    # clone docs repo
    Dir.mktmpdir do |docs_repo_clone_dir|
      Dir.chdir(docs_repo_clone_dir) do
        sh("git", "clone", docs_repo_url)
        Dir.chdir(docs_repo_name) do
          # copy docs generated in the previous step into the docs folder
          # and push the changes
          docs_destination_folder = "docs/#{version_number}"
          FileUtils.cp_r docs_generation_folder + "/.", docs_destination_folder
          docs_files.each do |file|
            FileUtils.cp file, "docs/"
          end

          # using sh instead of fastlane commands because fastlane would run
          # from the repo root
          sh("git", "add", docs_destination_folder)
          sh("git", "add", "docs/*.html")
          sh("git", "commit", "-m", "Update documentation for #{version_number}")
          sh("git", "push")
        end
      end
    end
  end

  desc "Builds the docs"
  private_lane :build_docs_shared do
    ENV["INCLUDE_DOCC_PLUGIN"] = "true"

    version_number = current_version_number
    docs_repo_name = ENV["DOCS_REPO_NAME"]
    ios_version = ENV['DOCS_IOS_VERSION'] || "16.1"

    UI.user_error!("Missing environment variable: DOCS_REPO_NAME") unless docs_repo_name

    hosting_base_path = File.join(docs_repo_name, version_number)

    docs_generation_folder = Dir.mktmpdir

    # swift package must be run from the dir that contains the Package.swift
    # output is generated in docs_generation_folder
    Dir.chdir("..") do
      build_symbols_for_docs

      sh("swift",
          "package",
          "--disable-sandbox",
          "--allow-writing-to-directory",
          docs_generation_folder,
          "generate-documentation",
          "--target",
          "RevenueCat",
          "--disable-indexing", # Produces a DocC archive that is best-suited for hosting online but incompatible with Xcode.
          "--platform",
          "name=iOS,version=#{ios_version}",
          "--output-path",
          docs_generation_folder,
          "--hosting-base-path",
          hosting_base_path,
          "--transform-for-static-hosting",
          "--enable-inherited-docs",
          "--additional-symbol-graph-dir", ".build")
    end

    docs_generation_folder # returning path from lane
  end

  desc "Build and deploy PurchaseTesterSwiftUI"
  lane :deploy_purchase_tester do |options|
    dry_run = options[:dry_run] || false

    project_root = File.expand_path("#{File.dirname(__FILE__)}/..")

    latest_changelog = File.read("#{project_root}/#{CHANGELOG_LATEST_PATH}")
    changelog = "Release #{current_version_number}:\n\n#{latest_changelog}"

    purchase_tester_root = "#{project_root}/Tests/TestingApps/PurchaseTesterSwiftUI"
    xcodeproj_path = "#{purchase_tester_root}/PurchaseTester.xcodeproj"

    # The following commands use configuration that lives in the PurchaseTesterSwiftUI directory,
    # like the Matchfile. So we move to that project's directory to run them.
    Dir.chdir("#{purchase_tester_root}/fastlane") do
      match(readonly: true, platform: "ios")
      increment_build_number(
        build_number: latest_testflight_build_number(platform: 'ios') + 1,
        xcodeproj: xcodeproj_path,
      )
    end

    build_ios_app(
      workspace: 'RevenueCat.xcworkspace',
      scheme: "PurchaseTester",
      destination: "generic/platform=ios",
      xcodebuild_formatter: '',
      export_method: 'app-store',
      export_options: {
        manageAppVersionAndBuildNumber: true,
        provisioningProfiles: {
          "com.revenuecat.sampleapp" => "match AppStore com.revenuecat.sampleapp",
          "com.revenuecat.sampleapp.watchkitapp" => "match AppStore com.revenuecat.sampleapp.watchkitapp"
        }
      }
    )

    # Wait 20 minutes for the build to be processed by Apple otherwise fail
    wait_processing_timeout_duration_in_seconds = 60 * 20

    if not dry_run
      upload_to_testflight(
      changelog: changelog,
      app_identifier: "com.revenuecat.sampleapp",
      wait_processing_timeout_duration: wait_processing_timeout_duration_in_seconds
    )
    else
      UI.message("Dry run mode enabled. Skipping upload to TestFlight")
    end

    # The following commands use configuration that lives in the PurchaseTesterSwiftUI directory,
    # like the Matchfile. So we move to that project's directory to run them.
    Dir.chdir("#{purchase_tester_root}/fastlane") do
      match(readonly: true, platform: "macos", additional_cert_types: "mac_installer_distribution")
      increment_build_number(
        build_number: latest_testflight_build_number(platform: 'osx') + 1,
        xcodeproj: xcodeproj_path,
      )
    end

    build_mac_app(
      workspace: 'RevenueCat.xcworkspace',
      scheme: "PurchaseTester",
      destination: 'generic/platform=macOS',
      xcodebuild_formatter: '',
      export_method: 'app-store',
      export_options: {
        manageAppVersionAndBuildNumber: true,
        provisioningProfiles: {
          "com.revenuecat.sampleapp" => "match AppStore com.revenuecat.sampleapp macos",
        }
      }
    )

    if not dry_run
      upload_to_testflight(
        changelog: changelog,
        app_platform: 'osx',
        app_identifier: "com.revenuecat.sampleapp",
        wait_processing_timeout_duration: wait_processing_timeout_duration_in_seconds
      )
    else
        UI.message("Dry run mode enabled. Skipping upload to TestFlight")
    end

  end

  desc "Deploy RCT Tester to TestFlight"
  lane :deploy_rct_tester do
    project_root = File.expand_path("#{File.dirname(__FILE__)}/..")
    bundle_id = "com.revenuecat.rcttester"
    provisioning_profile_name = "match AppStore #{bundle_id}"

    # Build release notes with SDK version and optional commit hash
    sdk_version = current_version_number
    if sdk_version.include?("SNAPSHOT")
      commit_hash = `git rev-parse --short HEAD`.strip
      changelog = "SDK version: #{sdk_version} (commit: #{commit_hash})"
    else
      changelog = "SDK version: #{sdk_version}"
    end

    UI.message("RCT Tester release notes: #{changelog}")

    rct_tester_root = "#{project_root}/Tests/TestingApps/RCTTester"

    # Write CI.xcconfig with the API key before generating the Tuist workspace.
    # Global.xcconfig (used by all Tuist projects) optionally includes CI.xcconfig,
    # so the key must be present before project generation.
    api_key = ENV['RCT_TESTER_REVENUECAT_API_KEY'] || ""
    if api_key.empty?
      UI.user_error!("RCT_TESTER_REVENUECAT_API_KEY environment variable is required")
    end

    xcconfig_path = "#{project_root}/CI.xcconfig"
    File.write(xcconfig_path, [
      "// Auto-generated for RCT Tester deployment. Do not commit to VCS.",
      "REVENUECAT_API_KEY = #{api_key}",
      "RCT_PROVISIONING_PROFILE = #{provisioning_profile_name}"
    ].join("\n"))
    UI.success("Created CI.xcconfig with REVENUECAT_API_KEY and RCT_PROVISIONING_PROFILE")

    # Generate the Tuist workspace (must happen after CI.xcconfig is written)
    tuist_generate(query: "RCTTester")

    workspace_path = "#{project_root}/RevenueCat-Tuist.xcworkspace"

    xcodeproj_path = "#{project_root}/Projects/RCTTester/RCTTester.xcodeproj"

    # Fetch certificates and provisioning profiles, and increment build number.
    # increment_build_number modifies the generated .xcodeproj directly,
    # which is the only reliable way to set the build number for Tuist projects.
    Dir.chdir("#{rct_tester_root}/fastlane") do
      match(readonly: true, platform: "ios")
      increment_build_number(
        build_number: latest_testflight_build_number(app_identifier: bundle_id, platform: 'ios') + 1,
        xcodeproj: xcodeproj_path,
      )
    end

    # PROVISIONING_PROFILE_SPECIFIER is set at the target level in the Tuist
    # Project.swift via the RCT_PROVISIONING_PROFILE xcconfig variable (written
    # to CI.xcconfig above). It must NOT be passed as an xcarg because xcargs
    # apply globally to all targets, including SPM resource bundle targets
    # (RevenueCat_RevenueCat, RevenueCat_RevenueCatUI) which don't support
    # provisioning profiles.
    build_ios_app(
      workspace: workspace_path,
      scheme: "RCTTester - Live Config",
      destination: "generic/platform=ios",
      xcodebuild_formatter: '',
      export_method: 'app-store',
      xcargs: "CODE_SIGN_STYLE=Manual " \
              "DEVELOPMENT_TEAM=8SXR2327BM " \
              "CODE_SIGN_IDENTITY='Apple Distribution'",
      export_options: {
        signingStyle: "manual",
        manageAppVersionAndBuildNumber: false,
        provisioningProfiles: {
          bundle_id => provisioning_profile_name,
        }
      }
    )

    # Wait 20 minutes for the build to be processed by Apple otherwise fail
    wait_processing_timeout_duration_in_seconds = 60 * 20

    upload_to_testflight(
      changelog: changelog,
      app_identifier: bundle_id,
      wait_processing_timeout_duration: wait_processing_timeout_duration_in_seconds
    )
  end


  desc "Clones or updates paywall-preview-resources repo"
  lane :fetch_paywall_preview_resources do
    Dir.chdir("../Tests") do
      if !File.directory?(PAYWALL_PREVIEW_RESOURCES_REPO_NAME)
        UI.important("Cloning paywall-preview-resources repo")
        sh "git", "clone", "git@github.com:RevenueCat/#{PAYWALL_PREVIEW_RESOURCES_REPO_NAME}.git"
      end

      commit = current_paywall_preview_resources_commit
      UI.message("Updating paywall-preview-resources repo to #{commit}")
      Dir.chdir(PAYWALL_PREVIEW_RESOURCES_REPO_NAME) do
        sh "git", "fetch", "origin"
        sh "git", "checkout", commit
      end
    end
  end

  desc "Generate Swift interface files for API change detection"
  lane :generate_swiftinterface do |options|
    require 'tmpdir'

    schemes = if options[:scheme]
                [options[:scheme]]
              else
                ApiDiffHelper::MODULES
              end

    # Create CI.xcconfig to exclude DEBUG from compilation conditions
    # This ensures the generated swiftinterface matches CI (release) builds
    # Using CI.xcconfig because the project automatically loads it instead of Local.xcconfig
    setup_swiftinterface_xcconfig

    begin
      Dir.mktmpdir("pr-swiftinterface-") do |temp_dir|
        project_root = File.expand_path("..")
        # One build per platform covers every requested module, because RevenueCatUI links
        # RevenueCat and so emits both interfaces. Asking for only the core stays on the core
        # scheme so it does not pay to compile RevenueCatUI as well.
        build_scheme = schemes.include?("RevenueCatUI") ? "RevenueCatUI" : "RevenueCat"

        threads = ApiDiffHelper::PLATFORMS.map do |platform_config|
          Thread.new(platform_config) do |config|
            ApiDiffHelper.build_swiftinterface(
              config,
              scheme: build_scheme,
              modules: schemes,
              project_root: project_root,
              output_dir: temp_dir
            )
          end
        end

        # Every build is awaited before reporting so one failure does not hide the others.
        errors = threads.map(&:value).reject { |result| result[:success] }.map { |result| result[:error] }
        UI.user_error!(errors.join("\n")) if errors.any?

        # Copy to expected location for CI artifacts
        FileUtils.rm_rf(ApiDiffHelper::PR_SWIFTINTERFACE_DIR)
        FileUtils.mkdir_p(ApiDiffHelper::PR_SWIFTINTERFACE_DIR)
        Dir.glob("#{temp_dir}/*.swiftinterface").each do |file|
          FileUtils.cp(file, ApiDiffHelper::PR_SWIFTINTERFACE_DIR)
        end
        UI.success("Swift interface files copied to #{ApiDiffHelper::PR_SWIFTINTERFACE_DIR}")
      end
    ensure
      # Clean up CI.xcconfig
      cleanup_swiftinterface_xcconfig
    end
  end

  desc "Setup CI.xcconfig for swiftinterface generation (excludes DEBUG)"
  private_lane :setup_swiftinterface_xcconfig do
    base_path = "#{Dir.pwd}/.."
    xcconfig_path = "#{base_path}/CI.xcconfig"
    local_xcconfig_path = "#{base_path}/Local.xcconfig"

    # Temporarily move Local.xcconfig out of the way if it exists
    # Local.xcconfig is loaded after CI.xcconfig and would override our settings
    if File.exist?(local_xcconfig_path)
      UI.message("Moving Local.xcconfig temporarily to prevent DEBUG override")
      FileUtils.mv(local_xcconfig_path, "#{local_xcconfig_path}.bak")
    end

    UI.message("Creating CI.xcconfig to exclude DEBUG compilation conditions")

    content = [
      "// Auto-generated for swiftinterface generation. Do not commit to VCS",
      "// Generated on #{Time.now}",
      "// Excludes DEBUG to match CI release builds",
      "",
      "SWIFT_ACTIVE_COMPILATION_CONDITIONS =",
      "SWIFT_ACTIVE_COMPILATION_CONDITIONS[sdk=xros*] = VISION_OS",
      "SWIFT_ACTIVE_COMPILATION_CONDITIONS[sdk=xrsimulator*] = VISION_OS"
    ]

    File.write(xcconfig_path, content.join("\n"))
    UI.success("✅ Created CI.xcconfig")
  end

  private_lane :cleanup_swiftinterface_xcconfig do
    base_path = "#{Dir.pwd}/.."
    xcconfig_path = "#{base_path}/CI.xcconfig"
    local_xcconfig_path = "#{base_path}/Local.xcconfig"

    FileUtils.rm_f(xcconfig_path)
    UI.message("Cleaned up CI.xcconfig")

    # Restore Local.xcconfig if it was moved
    if File.exist?("#{local_xcconfig_path}.bak")
      FileUtils.mv("#{local_xcconfig_path}.bak", local_xcconfig_path)
      UI.message("Restored Local.xcconfig")
    end
  end

  desc "Check API changes and report results"
  lane :check_api_changes do |options|
    schemes = if options[:scheme]
                [options[:scheme]]
              else
                ApiDiffHelper::MODULES
              end

    failed_platforms = []

    schemes.each do |scheme|
      prefix = ApiDiffHelper.api_file_prefix(scheme)

      ApiDiffHelper::PLATFORM_CHECKS.each do |platform|
        old_file = "../api/#{prefix}-api#{platform[:suffix]}.swiftinterface"
        new_file = "#{ApiDiffHelper::PR_SWIFTINTERFACE_DIR}/#{scheme}#{platform[:suffix]}.swiftinterface"

        result = ApiDiffHelper.run_api_diff(old_file, new_file, "#{scheme} #{platform[:name]}")
        failed_platforms << result unless result[:success]
      end
    end

    if failed_platforms.any?
      ApiDiffHelper.print_failure_summary(failed_platforms)
    else
      UI.success("✅ No API changes detected")
    end


    # Regenerated baselines make the freshness diff empty, so the gate needs an earlier base.
    runner = ->(*command) { sh(*command, log: false) }
    branch = ApiDiffHelper.current_branch(runner: runner)
    on_main = ApiDiffHelper.main_branch?(branch)
    comparison_base = ApiDiffHelper.resolve_comparison_base(runner: runner, branch: branch)
    UI.message("Comparing #{branch} against #{comparison_base}")

    breaks_by_scheme = {}
    reports_by_target = {}

    schemes.each do |scheme|
      base_dir = File.join(ApiDiffHelper::BASE_SWIFTINTERFACE_DIR, scheme)
      ApiDiffHelper.extract_baselines_at(comparison_base, base_dir, scheme, runner: runner)
      prefix = ApiDiffHelper.api_file_prefix(scheme)
      scheme_breaks = []

      ApiDiffHelper::PLATFORM_CHECKS.each do |platform|
        old_file = File.join(base_dir, "#{prefix}-api#{platform[:suffix]}.swiftinterface")
        new_file = "#{ApiDiffHelper::PR_SWIFTINTERFACE_DIR}/#{scheme}#{platform[:suffix]}.swiftinterface"
        next unless File.exist?(new_file)

        target_name = "#{scheme} #{platform[:name]}"
        report = ApiDiffHelper.public_api_diff_report(
          tool: "public-api-diff",
          old_file: old_file,
          new_file: new_file,
          target_name: target_name
        )
        reports_by_target[target_name] = report
        scheme_breaks.concat(ApiDiffHelper.breaking_changes(report, new_file))
      end

      breaks_by_scheme[scheme] = ApiDiffHelper.dedupe_breaks(scheme_breaks)
    end

    # The gate and the Slack summary speak for the whole PR, so they see every module's breaks.
    # Each PR comment section speaks for one module and gets only its own, or a break in one
    # module would be listed under the other and open a section for a module that never changed.
    all_breaks = ApiDiffHelper.dedupe_breaks(breaks_by_scheme.values.flatten)

    labels = []
    if all_breaks.any?
      begin
        labels = pr_labels_for_api_gate
      rescue StandardError => e
        # Unreadable labels must not abort before the report is published, and an unlabelled
        # PR is the blocking answer, so a failure here degrades rather than raising.
        UI.important("Could not read the PR labels (#{e.message}), treating the PR as unlabelled")
      end
    end

    ApiDiffHelper.print_breaking_summary(all_breaks, labels)

    # main carries no pull request to comment on.
    # Informational: a GitHub outage must not decide whether the gate fails.
    unless on_main
      begin
        schemes.each do |scheme|
          upsert_api_diff_comment(
            scheme: scheme,
            reports_by_target: reports_by_target.select { |target, _r| target.start_with?("#{scheme} ") },
            breaks: breaks_by_scheme.fetch(scheme, []),
            labels: labels
          )
        end
      rescue StandardError => e
        UI.important("Could not publish the API diff report: #{e.message}")
      end
    end

    if failed_platforms.any?
      UI.user_error!("API changes detected! Update baseline files if changes are intentional.")
    end

    if ApiDiffHelper.gate_blocked?(all_breaks, labels)
      # main has no PR to read the label from, and the PR run already gated the change.
      if on_main
        UI.important("Breaking public API changes on main, already gated on the PR run")
      else
        UI.user_error!("Breaking public API changes detected. Add #{ApiDiffHelper::BREAKING_CHANGE_LABEL} if intentional.")
      end
    end

    # Last, and only from main: nothing below can fail the job, so a rerun cannot post twice, and
    # a run whose baselines did not match never announces a surface it could not validate.
    if on_main
      begin
        notify_api_changes_on_slack(
          reports_by_target: reports_by_target,
          breaks: all_breaks,
          labels: labels
        )
      rescue StandardError => e
        UI.important("Could not announce the API change: #{e.message}")
      end
    end
  end

  private_lane :upsert_api_diff_comment do |options|
    pr_number = detect_pr_number
    token = ENV["DANGER_GITHUB_API_TOKEN"]

    if pr_number.nil? || token.nil? || token.empty?
      UI.message("No PR context, skipping the API diff comment")
      next
    end

    repo = "RevenueCat/#{REPO_NAME}"
    existing = find_pr_comment(repo, pr_number, token, ApiDiffHelper::API_DIFF_COMMENT_MARKER)

    unless ApiDiffHelper.comment_needed?(options[:reports_by_target], options[:breaks], existing&.dig("body"), options[:scheme])
      UI.message("No public API changes and none reported before, skipping the API diff comment")
      next
    end

    section = ApiDiffHelper.api_diff_comment_section(
      options[:scheme], options[:reports_by_target], options[:breaks], options[:labels]
    )

    if existing
      # The other module owns its own section of this comment; re-reading it each time is what
      # lets the second write land beside the first rather than on top of it.
      body = ApiDiffHelper.merge_api_diff_comment(existing["body"], options[:scheme], section)

      UI.message("Updating API diff comment #{existing['id']}")
      github_api(
        server_url: 'https://api.github.com',
        api_token: token,
        http_method: 'PATCH',
        path: "/repos/#{repo}/issues/comments/#{existing['id']}",
        body: { body: body }
      )
    else
      UI.message("Creating the API diff comment")
      github_api(
        server_url: 'https://api.github.com',
        api_token: token,
        http_method: 'POST',
        path: "/repos/#{repo}/issues/#{pr_number}/comments",
        body: { body: ApiDiffHelper.merge_api_diff_comment(nil, options[:scheme], section) }
      )
    end
  end

  # main has no PR comment to carry a warning, so every outcome is reported in the job log.
  private_lane :notify_api_changes_on_slack do |options|
    breaks = options[:breaks]
    changed_targets = options[:reports_by_target].select { |_t, r| ApiDiffHelper.api_changes_reported?(r) }

    if breaks.empty? && changed_targets.empty?
      UI.message("No public API changes, nothing to post to Slack")
      next
    end

    bot_token = ENV["SLACK_ACCESS_TOKEN_CIRCLE_CI_NOTIFY_ORB_IOS"]
    channel = ENV["SLACK_CHANNEL_SDK_NEW_API"]

    source = api_gate_commit_link
    modules = ApiDiffHelper.changed_modules(options[:reports_by_target])

    summary = ApiDiffHelper.slack_summary(
      breaks,
      options[:labels],
      source: source,
      new_declarations: ApiDiffHelper.added_declarations(options[:reports_by_target]),
      modules: modules,
      modifications: ApiDiffHelper.modified_declarations(options[:reports_by_target])
    )
    request = ApiDiffHelper.slack_post_request(summary, bot_token: bot_token, channel: channel)

    if request.nil?
      UI.important("No Slack credential reachable. Expected SLACK_ACCESS_TOKEN_CIRCLE_CI_NOTIFY_ORB_IOS from the slack-secrets context plus SLACK_CHANNEL_SDK_NEW_API. Skipping.")
      next
    end

    begin
      ApiDiffHelper.post_slack_message(request)
      UI.success("Posted the API change summary to Slack")
    rescue StandardError => e
      UI.important("The public API changed, but it was not announced in the SDK API feed: #{e.message}")
    end
  end

  # The commit page carries the PR link, so the message needs only the commit.
  private_lane :api_gate_commit_link do
    sha = sh("git", "rev-parse", "HEAD", log: false).strip
    next "" if sha.empty?

    "<https://github.com/RevenueCat/#{REPO_NAME}/commit/#{sha}|#{sha[0, 7]}>"
  end


  lane :deploy_to_spm do
    source_repo = ENV["CIRCLE_REPOSITORY_URL"] || "https://github.com/revenuecat/#{REPO_NAME}"
    destination_repo = source_repo.sub(/\.git\z/, "-spm.git")

    git_clone_and_push(
      source_repo: source_repo,
      destination_repo: destination_repo
    )
  end

  desc "Create or delete sandbox testers"
  lane :sandbox_testers do
    Spaceship::ConnectAPI.login(use_portal: false)

    UI.deprecated("Email pattern will be used when creating and deleting multiple testers")
    UI.deprecated("fastlane will increment a number before '@'")
    UI.deprecated("Example: testparty.v4@revenuecat.com will make: ")
    UI.deprecated("   testparty.v4.1@revenuecat.com")
    UI.deprecated("   testparty.v4.2@revenuecat.com")
    UI.deprecated("   testparty.v4.3@revenuecat.com")
    email_pattern = UI.input("Sanbox Tester Email pattern:")

    options = ["Create", "Delete"]
    option = UI.select("What do you want to do?", options)

    if option == options[0]
      sandbox_testers_create(email_pattern: email_pattern)
    elsif option == options[1]
      sandbox_testers_delete(email_pattern: email_pattern)
    end
  end

  desc "Enable customer center development by cherry-picking a specific commit"
  lane :enable_customer_center do |options|
    commit_hash = options[:commit_hash] || "517ad0bf5"

    UI.message("Applying changes from commit to enable customer center: #{commit_hash}")

    begin
      sh("git", "cherry-pick", "--no-commit", commit_hash)
      UI.success("Successfully applied changes for customer center development from commit #{commit_hash}")
      UI.important("Changes are now in your working directory. Review and stage/commit as needed.")
    rescue => ex
      UI.error("Failed to enable customer center. Error while applying changes from commit #{commit_hash}")
      UI.error(ex.message)

      if UI.confirm("Do you want to abort the customer center enablement?")
        sh("git", "cherry-pick", "--abort")
        UI.message("Customer center enablement aborted")
      end
    end
  end

  private_lane :sandbox_testers_delete do |options|
    email_pattern = options[:email_pattern]

    email_prefix = email_pattern.split('@').first

    testers = Spaceship::ConnectAPI::SandboxTester.all.select do |tester|
      tester.email.start_with?(email_prefix)
    end

    UI.important("Found testers:")
    testers.each do |tester|
      UI.message(tester.email)
    end

    if UI.confirm("Do you want to delete these sandbox testers?")
      testers.each do |tester|
        UI.message("Deleting #{tester.email}...")
        tester.delete!
      end
    end
  end

  private_lane :sandbox_testers_create do |options|
    email_pattern = options[:email_pattern]

    number = UI.input("Number of testers:").to_i
    UI.user_error!("Number needs to be more than 0") if number <= 0
    UI.user_error!("Number needs to be less than 51") if number > 50

    password = UI.input("Sandbox Tester Password (ex: 'SuperSecret01'):")

    question = UI.input("Sandbox Tester Secret Question (ex: 'Secret'):")
    answer = UI.input("Sandbox Tester Secret Answer (ex: 'Answer'):")

    emails = (1..number).map do |i|
      email_pattern.gsub('@', ".#{i.to_s}@")
    end

    emails.each do |email|
      data = {
        first_name: "Tester",
        last_name: "FastlaneCreated",
        email: email,
        password: password,
        confirm_password: password,
        secret_question: question,
        secret_answer: answer,
        birth_date: "1990-01-01",
        app_store_territory: "USA"
      }
      UI.message("Creating #{email}...")
      Spaceship::ConnectAPI::SandboxTester.create(**data)
    end
  end

  lane :notify_pr_ci_failure do
    pr_number = detect_pr_number
    unless pr_number
      UI.message("Not a PR build, skipping.")
      next
    end

    token = ENV["DANGER_GITHUB_API_TOKEN"]
    unless token && !token.empty?
      UI.message("DANGER_GITHUB_API_TOKEN not set, skipping.")
      next
    end

    repo = "RevenueCat/purchases-ios"
    job = ENV["CIRCLE_JOB"]
    build_url = ENV["CIRCLE_BUILD_URL"]
    marker = "<!-- cci-test-failure:#{job} -->"

    failed = []
    Dir.glob("test_output/**/*.{junit,xml}").each do |f|
      begin
        doc = REXML::Document.new(File.read(f))
        REXML::XPath.each(doc, "//testcase[failure or error]") do |tc|
          classname = tc.attributes["classname"].to_s
          name = tc.attributes["name"].to_s
          label = classname.empty? ? name : "#{classname}/#{name}"
          failed << label
        end
      rescue => e
        UI.important("Skipping #{f}: #{e.message}")
      end
    end
    failed = failed.uniq.sort
    UI.message("Failed/errored tests: #{failed.size}")

    comment_body = if failed.any?
      failed_list = failed.map { |t| "- #{t}" }.join("\n")
      "#{marker}\n## ❌ CI Job Failed — `#{job}`\n\n[View Build](#{build_url})\n\n**Failed Tests**\n\n#{failed_list}"
    else
      "#{marker}\n## ❌ CI Job Failed — `#{job}`\n\n[View Build](#{build_url})"
    end

    existing_id = find_pr_comment_with_marker(repo, pr_number, token, marker)
    if existing_id
      UI.message("Updating existing comment #{existing_id}")
      github_api(
        server_url: 'https://api.github.com',
        api_token: token,
        http_method: 'PATCH',
        path: "/repos/#{repo}/issues/comments/#{existing_id}",
        body: { body: comment_body }
      )
    else
      UI.message("Creating new comment")
      github_api(
        server_url: 'https://api.github.com',
        api_token: token,
        http_method: 'POST',
        path: "/repos/#{repo}/issues/#{pr_number}/comments",
        body: { body: comment_body }
      )
    end
  end

  lane :clear_pr_ci_failure do
    pr_number = detect_pr_number
    unless pr_number
      UI.message("Not a PR build, skipping.")
      next
    end

    token = ENV["DANGER_GITHUB_API_TOKEN"]
    unless token && !token.empty?
      UI.message("DANGER_GITHUB_API_TOKEN not set, skipping.")
      next
    end

    repo = "RevenueCat/purchases-ios"
    job = ENV["CIRCLE_JOB"]
    marker = "<!-- cci-test-failure:#{job} -->"

    begin
      existing_id = find_pr_comment_with_marker(repo, pr_number, token, marker)
      if existing_id
        UI.message("Deleting stale failure comment #{existing_id}")
        github_api(
          server_url: 'https://api.github.com',
          api_token: token,
          http_method: 'DELETE',
          path: "/repos/#{repo}/issues/comments/#{existing_id}"
        )
      else
        UI.message("No failure comment to delete.")
      end
    rescue => e
      UI.important("Failed to clear PR failure comment: #{e.message}")
    end
  end

end

platform :mac do
  desc "Runs tests on macOS"
  lane :test_macos do |options|
    generate_snapshots = ENV["CIRCLECI_TESTS_GENERATE_SNAPSHOTS"] == "true"
    if generate_snapshots
      test_plan = "CI-RevenueCat-Snapshots"
    else
      test_plan = "CI-RevenueCat"
    end

    xcargs = "-testPlan #{test_plan} CODE_SIGN_IDENTITY='' CODE_SIGNING_REQUIRED=NO"
    # This test plan runs in a random order and some tests are timing sensitive, so an
    # individual failure is usually load on the runner rather than a real regression.
    # `scan` gives the iOS and tvOS lanes 5 retries; this lane drives xcodebuild directly,
    # so ask xcodebuild to retry the failing tests instead.
    xcargs += " -retry-tests-on-failure -test-iterations 3" unless generate_snapshots

    begin
      xcodebuild(
        workspace: 'RevenueCat.xcworkspace',
        scheme: 'RevenueCat',
        destination: "platform=macOS,arch=arm64",
        output_style: :basic,
        result_bundle_path: 'fastlane/test_output/xctest/macos/revenuecat.xcresult',
        report_formats: [:junit],
        report_path: 'fastlane/test_output/xctest/macos/revenuecat/tests.xml',
        test: true,
        xcargs: xcargs
      )
    rescue => e
      # Equivalent to `fail_build: !generate_snapshots`
      raise e if !generate_snapshots
    end
  end

end

def check_no_git_tag_exists(version_number)
  if git_tag_exists(tag: version_number, remote: true, remote_name: 'origin')
    raise "git tag with version #{version_number} already exists!"
  else
    UI.message("✅ git tag with version #{version_number} doesn't exist")
  end
end

def check_no_github_release_exists(version_number)
  found_release_number = get_github_release(url: "revenuecat/purchases-ios", version: version_number)
  raise "Release with version #{version_number} already exists!" unless found_release_number.nil?
end

lane :check_pods do
  pod_lib_lint(
    verbose: true,
    podspec: 'RevenueCat.podspec',
    platforms: 'ios,osx,tvos,watchos,visionos',
    fail_fast: true
  )
  pod_lib_lint(
    verbose: true,
    podspec: 'RevenueCatUI.podspec',
    include_podspecs: 'RevenueCat.podspec',
    platforms: 'ios,osx,tvos,watchos,visionos',
    fail_fast: true
  )
end

desc "Removes some directories before building with Carthage to prevent unnecessary or problematic scheme resolution (e.g., Magic Weather SwiftUI.xcodeproj or v3LoadShedderIntegration.xcodeproj). Also removes v3LoadShedderIntegration and v4LoadShedderIntegration directories to prevent xcodebuild from timing out when scanning for projects."
lane :prepare_for_carthage do
  Dir.chdir("#{File.dirname(__FILE__)}/../") do
    sh("rm", "-rf", "Examples")
    sh("rm", "-rf", "Tests/v3LoadShedderIntegration")
    sh("rm", "-rf", "Tests/v4LoadShedderIntegration")
  end
end

lane :load_spm_dependencies do
  Dir.chdir("#{File.dirname(__FILE__)}/../") do
    sh("xcodebuild", "-list")
  end
end

def paywall_preview_resources_commit_file
  "#{File.dirname(__FILE__)}/../Tests/#{PAYWALL_PREVIEW_RESOURCES_REPO_NAME}-commit"
end
def current_paywall_preview_resources_commit
  File.read(paywall_preview_resources_commit_file).strip
end
def update_paywall_preview_resources_commit(hash)
  File.write(paywall_preview_resources_commit_file, hash)
end

def find_pr_comment_with_marker(repo, pr_number, token, marker)
  find_pr_comment(repo, pr_number, token, marker)&.fetch("id")
end

def find_pr_comment(repo, pr_number, token, marker)
  # A missed marker posts a duplicate instead of updating.
  page = 1

  loop do
    resp = github_api(
      server_url: 'https://api.github.com',
      api_token: token,
      http_method: 'GET',
      path: "/repos/#{repo}/issues/#{pr_number}/comments?per_page=100&page=#{page}"
    )
    comments = resp[:json] || []
    comment = comments.find { |c| c["body"].to_s.include?(marker) }
    return comment if comment
    return nil if comments.count < 100

    page += 1
  end
end
