default_platform :ios
opt_out_usage
skip_docs

require 'json'
require 'net/http'
require 'xcodeproj'
import 'Sonarfile'
import 'Allurefile'

xcode_version = ENV['XCODE_VERSION'] || '16.4'
xcode_project = 'StreamChatSwiftUI.xcodeproj'
sdk_names = ['StreamChatSwiftUI']
github_repo = ENV['GITHUB_REPOSITORY'] || 'GetStream/stream-chat-swiftui'
derived_data_path = 'derived_data'
source_packages_path = 'spm_cache'
buildcache_xcargs = 'CC=clang CPLUSPLUS=clang++ LD=clang LDPLUSPLUS=clang++'
is_localhost = !is_ci
project_package_resolved = "#{xcode_project}/project.xcworkspace/xcshareddata/swiftpm/Package.resolved"
swift_environment_path = File.absolute_path('../Sources/StreamChatSwiftUI/Generated/SystemEnvironment+Version.swift')
@force_check = false

before_all do |lane|
  if is_ci
    setup_ci
    setup_git_config
    select_xcode(version: xcode_version) unless [:sonar_upload, :allure_launch, :allure_upload, :copyright, :pod_lint, :merge_main].include?(lane)
  end
end

after_all do |lane|
  stop_sinatra if lane == :test_e2e_mock
end

lane :build_xcframeworks do
  match_me
  output_directory = File.absolute_path("#{Dir.pwd}/../Products")
  team_id = File.read('Matchfile').match(/team_id\("(.*)"\)/)[1]
  codesign = ["codesign --timestamp -v --sign 'Apple Distribution: Stream.io Inc (#{team_id})'"]
  sdk_names.each do |sdk|
    create_xcframework(
      project: xcode_project,
      scheme: sdk,
      destinations: ['iOS'],
      include_BCSymbolMaps: true,
      include_debug_symbols: true,
      xcframework_output_directory: output_directory,
      remove_xcarchives: true
    )
    sh('../Scripts/removeUnneededSymbols.sh', sdk, output_directory)
    codesign << lane_context[SharedValues::XCFRAMEWORK_OUTPUT_PATH]
  end

  remove_swiftui_core_module_shadow(output_directory: output_directory)
  remove_stream_chat_module_shadow(output_directory: output_directory)
  sh(codesign.join(' ')) # We need to sign all frameworks at once
end

# https://linear.app/stream/issue/IOS-630
private_lane :remove_swiftui_core_module_shadow do |options|
  Dir.glob("#{options[:output_directory]}/**/*.swiftinterface").each do |file|
    if File.file?(file)
      UI.important("Removing the SwiftUICore module's shadow at: #{file}...")
      File.write(file, File.read(file).gsub('SwiftUICore.', ''))
    end
  end
end

# Swift emits an invalid module interface when a public type has the same name as a module, see https://github.com/swiftlang/swift/issues/56573
private_lane :remove_stream_chat_module_shadow do |options|
  Dir.glob("#{options[:output_directory]}/**/*.swiftinterface") do |file|
    if File.file?(file)
      UI.important("Removing the StreamChat module's shadow at: #{file}...")
      File.write(file, File.read(file).gsub('StreamChat.', ''))
    end
  end
end

desc 'Release a new version'
lane :release do |options|
  artifacts_path = File.absolute_path('../StreamChatSwiftUIArtifacts.json')
  extra_changes = lambda do |release_version|
    # Set the framework version on the artifacts
    artifacts = JSON.parse(File.read(artifacts_path))
    artifacts[release_version.to_s] = "https://github.com/#{github_repo}/releases/download/#{release_version}/StreamChatSwiftUI.zip"
    File.write(artifacts_path, JSON.dump(artifacts))

    # Set the framework version in SystemEnvironment+Version.swift
    old_content = File.read(swift_environment_path)
    current_version = old_content[/version: String = "([^"]+)"/, 1]
    new_content = old_content.gsub(current_version, release_version)
    File.open(swift_environment_path, 'w') { |f| f.puts(new_content) }

    # Update sdk sizes
    Dir.chdir('fastlane') { update_img_shields_sdk_sizes }
  end

  pod_lint
  release_ios_sdk(
    version: options[:version],
    bump_type: options[:type],
    sdk_names: sdk_names,
    podspec_names: ['StreamChatSwiftUI', 'StreamChatSwiftUI-XCFramework'],
    github_repo: github_repo,
    extra_changes: extra_changes,
    create_pull_request: true
  )
end

lane :merge_release do |options|
  merge_release_to_main(author: options[:author])
  sh('gh workflow run release-publish.yml --ref main')
end

lane :merge_main do
  merge_main_to_develop
  update_release_version_to_snapshot(file_path: swift_environment_path)
  ensure_git_branch(branch: 'develop')
  sh("git add #{swift_environment_path}")
  sh("git commit -m 'Update release version to snapshot'")
  sh('git push')
end

desc "Publish a new release to GitHub and CocoaPods"
lane :publish_release do |options|
  release_version = get_sdk_version_from_environment
  UI.user_error!("Release #{release_version} has already been published.") if git_tag_exists(tag: release_version, remote: true)
  UI.user_error!('Release version cannot be empty') if release_version.to_s.empty?
  ensure_git_branch(branch: 'main')

  clean_products
  build_xcframeworks
  compress_frameworks
  clean_products

  publish_ios_sdk(
    skip_git_status_check: false,
    version: release_version,
    sdk_names: sdk_names,
    podspec_names: ['StreamChatSwiftUI', 'StreamChatSwiftUI-XCFramework'],
    github_repo: github_repo,
    upload_assets: ['Products/StreamChatSwiftUI.zip']
  )

  update_spm(version: release_version)

  sh('gh workflow run merge-main-to-develop.yml --ref main')
end

lane :get_sdk_version_from_environment do
  File.read(swift_environment_path).match(/String\s+=\s+"([\d.]+).*"/)[1]
end

private_lane :appstore_api_key do
  @appstore_api_key ||= app_store_connect_api_key(
    key_id: 'MT3PRT8TB7',
    issuer_id: '69a6de96-0738-47e3-e053-5b8c7c11a4d1',
    key_content: ENV.fetch('APPSTORE_API_KEY', nil),
    in_house: false
  )
end

desc "Updates StreamChat dependency locally. Usage: `bundle exec fastlane update_stream_chat version:4.56.0`"
lane :update_stream_chat do |options|
  raise UI.user_error!('Provide a version.') unless options[:version]

  Dir.chdir('..') do
    file = 'Package.swift'
    current_stream_chat_version = File.read(file)[/stream-chat-swift\.git", from: "([\d.]+)"\)/, 1]
    File.write(file, File.read(file).gsub(/(stream-chat-swift\.git", from: ")[\d.]+"/, "\\1#{options[:version]}\""))

    file = 'StreamChatSwiftUI-XCFramework.podspec'
    File.write(file, File.read(file).gsub(/(StreamChat-XCFramework', '~> )[\d.]+'/, "\\1#{options[:version]}'"))

    file = 'StreamChatSwiftUI.podspec'
    File.write(file, File.read(file).gsub(/(StreamChat', '~> )[\d.]+'/, "\\1#{options[:version]}'"))

    file = 'StreamChatSwiftUI.xcodeproj/project.pbxproj'
    content = File.read(file)
    if content.include?("minimumVersion = #{current_stream_chat_version}")
      File.write(file, content.gsub("minimumVersion = #{current_stream_chat_version}", "minimumVersion = #{options[:version]}"))
    elsif content.include?('branch = develop')
      File.write(file, content.gsub('kind = branch', "minimumVersion = #{options[:version]}").gsub('branch = develop', 'kind = upToNextMajorVersion'))
    else
      UI.user_error!("Something went wrong after trying to modify #{file}.")
    end
  end

  pr_create(
    title: "Update StreamChat dependency to #{options[:version]}",
    base_branch: 'develop',
    head_branch: "ci/update-stream-chat-dependency-#{Time.now.to_i}",
    github_repo: github_repo
  )
end

lane :pod_lint do
  lint_required = true
  Dir.chdir('..') do
    sh("xcodebuild -resolvePackageDependencies -clonedSourcePackagesDirPath #{source_packages_path}")
    dependencies = JSON.parse(File.read(project_package_resolved))
    lint_required = dependencies['pins'].none? { |dependency| dependency['identity'] == 'stream-chat-swift' && dependency['state']['branch'] }
  end

  if lint_required
    pod_lib_lint(podspec: 'StreamChatSwiftUI.podspec', allow_warnings: true)
  else
    UI.important('Pod linting skipped: stream-chat-swift targets a development branch')
  end
end

desc "If `readonly: true` (by default), installs all Certs and Profiles necessary for development and ad-hoc.\nIf `readonly: false`, recreates all Profiles necessary for development and ad-hoc, updates them locally and remotely."
lane :match_me do |options|
  custom_match(
    api_key: appstore_api_key,
    app_identifier: ['io.getstream.iOS.DemoAppSwiftUI'],
    readonly: options[:readonly],
    register_device: options[:register_device]
  )
end

desc 'Builds the latest version of Demo app and uploads it to TestFlight'
lane :swiftui_testflight_build do |options|
  is_manual_upload = is_localhost || ENV['GITHUB_EVENT_NAME'] == 'workflow_dispatch'
  configuration = options[:configuration].to_s.empty? ? 'Release' : options[:configuration]

  match_me

  sdk_version = get_sdk_version_from_environment
  UI.important("[TestFlight] Uploading DemoApp version: #{sdk_version}")

  testflight_build(
    api_key: appstore_api_key,
    xcode_project: xcode_project,
    sdk_target: 'StreamChatSwiftUI',
    app_version: sdk_version,
    app_target: 'DemoAppSwiftUI',
    app_identifier: 'io.getstream.iOS.DemoAppSwiftUI',
    configuration: configuration,
    use_changelog: true,
    is_manual_upload: is_manual_upload
  )
end

desc 'Runs tests in Debug config'
lane :test_ui do |options|
  next unless is_check_required(sources: sources_matrix[:ui], force_check: @force_check)

  record_mode = options[:record].to_s == 'true'
  remove_snapshots if record_mode

  update_testplan_on_ci(path: 'StreamChatSwiftUITests/Tests/StreamChatSwiftUI.xctestplan')

  scan(
    project: xcode_project,
    scheme: 'StreamChatSwiftUI',
    testplan: 'StreamChatSwiftUI',
    configuration: 'Debug',
    clean: is_localhost,
    derived_data_path: derived_data_path,
    cloned_source_packages_path: source_packages_path,
    result_bundle: true,
    devices: options[:device],
    build_for_testing: options[:build_for_testing],
    skip_build: options[:skip_build],
    number_of_retries: options[:record].to_s.empty? ? 1 : nil,
    xcargs: buildcache_xcargs,
    fail_build: !record_mode
  )

  if record_mode && is_ci
    png_files = git_status(ext: '.png').map { |_, png| png }.flatten
    next if png_files.empty?

    # Discard all files apart from the snapshots
    Dir.chdir('..') do
      png_files.each { |png| sh("git add #{png}") || true }
      sh('git restore .')
    end

    pr_create(
      title: '[CI] Snapshots',
      base_branch: current_branch,
      head_branch: "#{current_branch}-snapshots-#{Time.now.to_i}"
    )
  else
    slather unless options[:build_for_testing]
  end
end

lane :build_test_app_and_frameworks do
  scan(
    project: xcode_project,
    scheme: 'StreamChatSwiftUITestsApp',
    testplan: 'StreamChatSwiftUITestsApp',
    result_bundle: true,
    derived_data_path: derived_data_path,
    cloned_source_packages_path: source_packages_path,
    clean: is_localhost,
    build_for_testing: true,
    xcargs: buildcache_xcargs
  )
end

desc 'Starts Sinatra web server'
lane :start_sinatra do
  sh('bundle exec ruby sinatra.rb > sinatra_log.txt 2>&1 &')
end

desc 'Stops Sinatra web server'
lane :stop_sinatra do
  sh('lsof -t -i:4567 | xargs kill -9')
end

desc 'Runs e2e ui tests using mock server in Debug config'
lane :test_e2e_mock do |options|
  next unless is_check_required(sources: sources_matrix[:e2e], force_check: @force_check)

  start_sinatra

  scan_options = {
    project: xcode_project,
    scheme: 'StreamChatSwiftUITestsApp',
    testplan: 'StreamChatSwiftUITestsApp',
    result_bundle: true,
    derived_data_path: derived_data_path,
    cloned_source_packages_path: source_packages_path,
    clean: is_localhost,
    test_without_building: options[:test_without_building],
    xcargs: buildcache_xcargs,
    devices: options[:device],
    prelaunch_simulator: is_ci,
    number_of_retries: 3
  }

  if ENV['MATRIX_SIZE'] && options[:batch]
    products_dir = File.expand_path("../#{derived_data_path}/Build/Products")
    xctestrun = Dir.glob(File.expand_path("#{products_dir}/*.xctestrun")).first
    tests = retrieve_xctest_names(xctestrun: xctestrun).values.flatten
    slice_size = (tests.size / ENV['MATRIX_SIZE'].to_f).ceil
    only_testing = []
    tests.each_slice(slice_size) { |test| only_testing << test }
    only_testing_batch = only_testing[options[:batch].to_i]
    scan_options[:only_testing] = only_testing_batch
    UI.important("Tests in total: #{only_testing.flatten.size}. Running #{only_testing_batch.size} of them ⌛️")
  end

  begin
    scan(scan_options)
  rescue StandardError
    failed_tests = retreive_failed_tests
    UI.important("Re-running #{failed_tests.size} failed tests ⌛️")
    scan(scan_options.merge(only_testing: failed_tests))
  end
end

private_lane :retreive_failed_tests do
  report_path = 'test_output/report.junit'
  raise UI.user_error!('There is no junit report to parse') unless File.file?(report_path)

  junit_report = Nokogiri::XML(File.read(report_path))
  failed_tests = []
  passed_tests = []
  suite_name = junit_report.xpath('//testsuite').first['name'].split('.').first
  junit_report.xpath('//testcase').each do |testcase|
    class_name = testcase['classname'].split('.').last
    test_name = testcase['name'].delete('()')

    if testcase.at_xpath('failure')
      failed_tests << "#{suite_name}/#{class_name}/#{test_name}"
    else
      passed_tests << "#{suite_name}/#{class_name}/#{test_name}"
    end
  end

  (failed_tests - passed_tests).uniq
end

desc 'Builds Demo app'
lane :build_demo do |options|
  next unless is_check_required(sources: sources_matrix[:sample_apps], force_check: @force_check)

  scan(
    project: xcode_project,
    scheme: 'DemoAppSwiftUI',
    clean: is_localhost,
    derived_data_path: derived_data_path,
    cloned_source_packages_path: source_packages_path,
    build_for_testing: true,
    devices: options[:device],
    xcargs: buildcache_xcargs
  )
end

desc 'Compresses the XCFrameworks into zip files'
lane :compress_frameworks do
  Dir.chdir('..') do
    FileUtils.cp('LICENSE', 'Products/LICENSE')
    Dir.chdir('Products') do
      sdk_names.each do |framework|
        sh("zip -r #{framework} ./#{framework}.xcframework ./LICENSE")
        sh("swift package compute-checksum #{framework}.zip")
      end
      sh('zip -r "StreamChat-All" ./*.xcframework ./LICENSE') if sdk_names.size > 1
    end
  end
end

desc 'Cleans Products and DerivedData folders'
lane :clean_products do
  Dir.chdir('..') do
    ['*.xcframework', '*.bundle', '*.BCSymbolMaps', '*.dSYMs', 'LICENSE'].each do |f|
      sh("rm -rf Products/#{f}")
    end
  end
end

desc 'Update XCFrameworks and submit to the SPM repository'
private_lane :update_spm do |options|
  version = options[:version] || ''
  UI.user_error!('You need to pass the version of the release you want to obtain the changelog from') unless version.length > 0

  # Generate Checksums
  stream_chat_swiftui_checksum = sh('swift package compute-checksum ../Products/StreamChatSwiftUI.zip').strip

  # Update SPM Repo
  spm_directory_name = 'StreamSPM'
  spm_directory = "../../#{spm_directory_name}"
  sh("git clone git@github.com:#{github_repo}-spm.git #{spm_directory}")

  Dir.chdir(spm_directory) do
    result = sh('basename `git rev-parse --show-toplevel`').strip
    UI.error("Not using #{spm_directory_name} repo") unless result.to_s == spm_directory_name

    file_lines = File.readlines('Package.swift')
    file_data = ''
    previous_module = ''

    file_lines.each do |line|
      formatted_line =
        case previous_module
        when "StreamChatSwiftUI"
          line.gsub(/(checksum: ")[a-z0-9]+(")/, "\\1#{stream_chat_swiftui_checksum}\\2")
        else
          line
        end

      url_pattern = %r{(releases/download/)[.0-9]+(/)}
      if line.match(url_pattern)
        formatted_line = line.gsub(url_pattern, "\\1#{version}\\2")
        previous_module = line.match(/([a-zA-Z]+).zip/).to_s.gsub(/.zip/, '')
      end

      file_data << formatted_line
    end

    # Write the new changes
    File.open('./Package.swift', 'w') { |file| file << file_data }

    # Update the repo
    sh('git add -A')
    sh("git commit -m 'Bump #{version}'")
    sh('git push')

    github_release = set_github_release(
      repository_name: "#{github_repo}-spm",
      api_token: ENV.fetch('GITHUB_TOKEN', nil),
      name: version,
      tag_name: version,
      commitish: 'main',
      description: "https://github.com/#{github_repo}/releases/tag/#{version}"
    )
    UI.success("New SPM release available: #{github_release['html_url']}")
  end

  # Clean Up
  sh("rm -rf #{spm_directory}")
end

private_lane :update_testplan_on_ci do |options|
  update_testplan(path: options[:path], env_vars: { key: 'CI', value: 'TRUE' }) if is_ci
end

desc 'Run fastlane linting'
lane :rubocop do
  next unless is_check_required(sources: sources_matrix[:ruby], force_check: @force_check)

  sh('bundle exec rubocop')
end

desc 'Run PR linting'
lane :lint_pr do
  danger(dangerfile: 'Dangerfile') if is_ci
end

desc 'Run source code formatting/linting'
lane :run_swift_format do |options|
  Dir.chdir('..') do
    strict = options[:strict] ? '--lint' : nil
    sources_matrix[:swiftformat].each do |path|
      sh("mint run swiftformat #{strict} --config .swiftformat #{path}")
      next if path.include?('Tests')

      sh("mint run swiftlint lint --config .swiftlint.yml --fix --progress --quiet --reporter json #{path}") unless strict
      sh("mint run swiftlint lint --config .swiftlint.yml --strict --progress --quiet --reporter json #{path}")
    end
  end
end

lane :install_runtime do |options|
  install_ios_runtime(version: options[:ios], custom_script: 'Scripts/install_ios_runtime.sh')
end

desc 'Remove UI Snapshots'
lane :remove_snapshots do |options|
  snapshots_path = "../StreamChatSwiftUITests/**/__Snapshots__/**/*.png"
  if options[:only_unchanged]
    pnf_files = git_status(ext: '.png')
    changed_snapshots = (pnf_files[:a] + pnf_files[:m]).map { |f| File.expand_path(f) }
    Dir.glob(snapshots_path).select { |f| File.delete(f) unless changed_snapshots.include?(File.expand_path(f)) }
  else
    Dir.glob(snapshots_path).select { |f| File.delete(f) }
  end
end

lane :sources_matrix do
  {
    e2e: ['Sources', 'StreamChatSwiftUITestsAppTests', 'StreamChatSwiftUITestsApp'],
    ui: ['Sources', 'StreamChatSwiftUITests', xcode_project],
    sample_apps: ['Sources', 'DemoAppSwiftUI', xcode_project],
    ruby: ['fastlane', 'Gemfile', 'Gemfile.lock'],
    size: ['Sources', xcode_project],
    sonar: ['Sources'],
    public_interface: ['Sources'],
    swiftformat: ['Sources', 'DemoAppSwiftUI', 'StreamChatSwiftUITests']
  }
end

lane :copyright do
  update_copyright(ignore: [derived_data_path, source_packages_path, 'vendor/'])
  next unless is_ci

  pr_create(
    title: '[CI] Update Copyright',
    head_branch: "ci/update-copyright-#{Time.now.to_i}"
  )
end

lane :validate_public_interface do
  next unless is_check_required(sources: sources_matrix[:public_interface], force_check: @force_check)

  # Run the analysis on the current branch
  original_branch = current_branch
  sh('interface-analyser analysis ../Sources/ public_interface_current.json')

  # Checkout the target branch
  target_branch = original_branch.include?('release/') ? 'main' : 'develop'
  sh("git fetch origin #{target_branch}")
  sh("git checkout #{target_branch}")

  # Run the analysis on the target branch
  sh('interface-analyser analysis ../Sources/ public_interface_previous.json')

  # Run diff
  report_path = 'interface-analyser-report.md'
  sh("interface-analyser diff public_interface_current.json public_interface_previous.json #{report_path}")

  # Check if report exists and is non-zero in size
  diff =
    if File.exist?(report_path) && File.size(report_path) > 0
      File.read(report_path).strip
    else
      '🚀 No changes affecting the public interface.'
    end

  # Generate markdown table for the PR comment
  header = '## Public Interface'
  content = "#{header}\n#{diff}"

  # Post PR comment if running in CI
  pr_comment(text: content, edit_last_comment_with_text: header) if is_ci

  # Checkout the original branch
  sh("git fetch origin #{original_branch}")
  sh("git checkout #{original_branch}")
end

lane :show_frameworks_sizes do |options|
  next unless is_check_required(sources: sources_matrix[:size], force_check: @force_check)

  sizes = options[:sizes] || frameworks_sizes
  show_sdk_size(branch_sizes: sizes, github_repo: github_repo)
  update_img_shields_sdk_sizes(sizes: sizes, open_pr: options[:open_pr]) if options[:update_readme]
end

lane :update_img_shields_sdk_sizes do |options|
  update_sdk_size_in_readme(
    open_pr: options[:open_pr] || false,
    readme_path: 'README.md',
    sizes: options[:sizes] || frameworks_sizes
  )
end

def frameworks_sizes
  root_dir = 'Build/SDKSize'
  archive_dir = "#{root_dir}/DemoApp.xcarchive"

  FileUtils.rm_rf("../#{root_dir}/")

  match_me

  gym(
    scheme: 'DemoAppSwiftUI',
    archive_path: archive_dir,
    export_method: 'ad-hoc',
    export_options: 'fastlane/sdk_size_export_options.plist'
  )

  # Parse the thinned size of Assets.car from Packaging.log
  assets_size_regex = %r{\b(\d+)\sbytes\sfor\s./Payload/DemoAppSwiftUI.app/Frameworks/StreamChatSwiftUI.framework/Assets.car\b}
  packaging_log_content = File.read("#{Gym.cache[:temporary_output_path]}/Packaging.log")
  match = packaging_log_content.match(assets_size_regex)
  assets_thinned_size = match[1].to_i

  frameworks_path = "../#{archive_dir}/Products/Applications/DemoAppSwiftUI.app/Frameworks"
  stream_chat_swiftui_size = File.size("#{frameworks_path}/StreamChatSwiftUI.framework/StreamChatSwiftUI")
  stream_chat_swiftui_size_kb = (stream_chat_swiftui_size + assets_thinned_size) / 1024.0

  { StreamChatSwiftUI: stream_chat_swiftui_size_kb.round(0) }
end
