# frozen_string_literal: true

default_platform(:mac)

# Load a secret from the keychain if it is not passed as an environment variable.
require "base64"

# Execute a shell command silently, without logging the step, the command or its output.
# Similar to `sh(..., log: false)` but without logging the step.
def silent_sh(command)
  Actions.sh_control_output(command, print_command: false, print_command_output: false)
end

# A helper that loads a secret from the environment variable or the keychain.
def load_secret(env_key)
  if ENV[env_key]
    unless ENV["CI"]
      # When the secret is passed as an environment variable locally, we store it in the keychain for future use.
      puts "Writting secret to keychain: #{env_key}"
      # Use base64 encoding to avoid dealing with new lines.
      sh("security add-generic-password -a '#{ENV["USER"]}' -s 'dev.getcmd.#{env_key}' -w '#{Base64.strict_encode64(ENV[env_key])}' -U")
    end
    return ENV[env_key]
  end

  puts "Loading secret from keychain: #{env_key}"
  encoded = %x(security find-generic-password -s 'dev.getcmd.#{env_key}' -w 2>/dev/null).strip
  return if encoded.empty?

  decoded = Base64.decode64(encoded)
  ENV[env_key] = decoded
end

# Find a version that doesn't conflict with existing tag, branch, or release
def find_available_version(initial_version)
  version = initial_version

  loop do
    existing_tag = silent_sh("git ls-remote origin refs/tags/v#{version}").strip
    existing_branch = silent_sh("git ls-remote origin refs/heads/release-v#{version}").strip

    # Check if GitHub release exists by attempting to fetch the release URL
    existing_release = begin
      http_status = silent_sh("curl -o /dev/null -s -w '%{http_code}' https://github.com/getcmd-dev/cmd/releases/tag/v#{version}").strip
      http_status == "200"
    rescue
      false
    end

    if existing_tag.empty? && existing_branch.empty? && !existing_release
      puts "Using version: #{version}"
      return version
    else
      conflicts = []
      conflicts << "tag" unless existing_tag.empty?
      conflicts << "branch" unless existing_branch.empty?
      conflicts << "release" if existing_release

      version_parts = version.split(".")
      version_parts[-1] = (version_parts[-1].to_i + 1).to_s
      new_version = version_parts.join(".")
      puts "Version #{version} conflicts with existing #{conflicts.join(", ")}. Bumping to #{new_version}."
      version = new_version
    end
  end
end

platform :mac do
  desc "Build the app in Debug configuration"
  lane :build_debug do
    build_mac_app(
      project: "./command.xcodeproj",
      configuration: "Debug",
      skip_archive: true,
      scheme: "command",
      # silent: true,
      xcodebuild_formatter: File.absolute_path("./xcodebuild_logger.sh"),
    )
  end

  desc "Build the app in Release configuration"
  lane :build_release do
    setup_ci if ENV["CI"]

    build_path = File.absolute_path("../build")

    # Resolve package dependencies first. This is to work around an issue where CI would freeze.
    sh("xcodebuild -resolvePackageDependencies -scheme command -project ../command.xcodeproj -configuration Release -derivedDataPath #{build_path}/derived_data -onlyUsePackageVersionsFromResolvedFile -parallelizeTargets")

    sh("../tools/release/configure_xcodeproj_for_release_build.sh")
    build_mac_app(
      destination: "platform=macOS,arch=arm64",
      skip_package_dependencies_resolution: true,
      project: "./command.xcodeproj",
      configuration: "Release",
      output_directory: "#{build_path}/release",
      derived_data_path: "#{build_path}/derived_data",
      output_name: "cmd.app",
      scheme: "command",
      skip_archive: true,
      skip_codesigning: true,
      xcodebuild_formatter: File.absolute_path("./xcodebuild_logger.sh"),
    )
    sh("../tools/release/cleanup_xcodeproj_after_release_build.sh")
  end

  desc "Build, archive and sign the app for distribution"
  lane :create_and_sign_release do |options|
    setup_ci if ENV["CI"]

    # Resolve package dependencies first. This is to work around an issue where CI would freeze.
    build_path = File.absolute_path("../build")
    sh("rm -rf #{build_path}")
    sh("xcodebuild -resolvePackageDependencies -scheme command -project ../command.xcodeproj -configuration Release -derivedDataPath #{build_path}/derived_data -onlyUsePackageVersionsFromResolvedFile -parallelizeTargets")

    version = options.fetch(:version, nil)

    # use a fine grained token with permissions for: Contents, Metadata (maybe also commit statuses and pull requests)
    personal_github_access_token = load_secret("FASTLANE_MACH_REPO_GITHUB_ACCESS_TOKEN")
    ENV["MATCH_PASSWORD"] = load_secret("MATCH_PASSWORD")
    readonly = true
    # The fine grained token should have permissions for: Contents, Metadata (maybe also commit statuses and pull requests)
    # I could not find how to pass it in the header. It is in the git URL. If it is not set, like when running locally and saving new certificates, we need to use the SSH URL.
    git_url = readonly ? "https://gsabran:#{personal_github_access_token}@github.com/gsabran/command-provisioning-profiles.git" : "git@github.com:gsabran/command-provisioning-profiles.git"
    match(
      type: "developer_id",
      team_id: "GP78T2GNXD",
      readonly: readonly,
      git_url: git_url,
    )
    update_code_signing_settings(
      code_sign_identity: "Developer ID Application",
      use_automatic_signing: false,
      path: "./command.xcodeproj",
    )

    certificate_name = ENV["sigh_dev.getcmd.command_developer_id_macos_certificate-name"]
    provisioning_profile_path = ENV["sigh_dev.getcmd.command_developer_id_macos_profile-path"]
    ### Find the sha1 to identify the right certificate when several with the same name are installed.
    certificate_sha1 = sh("security cms -D -i '#{provisioning_profile_path}' | plutil -extract DeveloperCertificates.0 raw -o - - | base64 -D | openssl x509 -inform DER -noout -fingerprint -sha1  | cut -d= -f2 | sed 's/://g'").strip
    if certificate_sha1.empty?
      raise "Could not get certificate SHA1 from provisioning profile at path: #{provisioning_profile_path}"
    end

    # Build and archive the app
    app_name = "cmd"
    app_path = "#{build_path}/release/#{app_name}.app"
    archive_path = "#{build_path}/release/#{app_name}.xcarchive"

    # Create a new build
    sh("../tools/release/configure_xcodeproj_for_release_build.sh")

    build_mac_app(
      destination: "platform=macOS,arch=arm64",
      skip_package_dependencies_resolution: true,
      project: "./command.xcodeproj",
      configuration: "Release",
      export_method: "developer-id",
      output_directory: "#{build_path}/release",
      archive_path: archive_path,
      derived_data_path: "#{build_path}/derived_data",
      output_name: app_name,
      scheme: "command",
      # silent: true,
      skip_archive: false,
      xcodebuild_formatter: File.absolute_path("./xcodebuild_logger.sh"),
    )
    sh("../tools/release/cleanup_xcodeproj_after_release_build.sh")

    # Strip DEBUG symbols from the binary to reduce size, after the archive has been created, and resign.
    app_binary_path = "#{app_path}/Contents/MacOS/cmd"
    strip_debug_symbols_and_resign(app_path: app_path, app_binary_path: app_binary_path, build_path: build_path, certificate_name: certificate_name, certificate_sha1: certificate_sha1)

    # Notarize the app
    app_store_connect_api_key(
      key_id: load_secret("NOTARY_KEY_ID"),
      issuer_id: load_secret("NOTARY_ISSUER_ID"),
      key_content: load_secret("NOTARY_P8"),
    )
    notarize(
      package: app_path,
      verbose: false,
      print_log: true,
      use_notarytool: true,
    )

    # Create a DMG file for distribution using create-dmg
    dmg_path = "#{build_path}/release/#{app_name}.dmg"
    sh("create-dmg '#{app_path}' --identity #{certificate_sha1} --no-version-in-filename --overwrite")
    sh("mv cmd.dmg #{dmg_path}")

    puts "Verifying code signature..."
    sh("codesign --verify --deep --strict --verbose=2 '#{app_path}'")
    sh("spctl -a -t exec -vv '#{app_path}'")
    sh("codesign --verify --deep --strict --verbose=2 '#{dmg_path}'")

    # Create metadata for the new release

    # we need to re-package after notarization and stapling.
    # we have to use `ditto --sequesterRsrc` rather than zip
    # because the default strategy stores resource forks as
    # ._Foo but codesigning doesn't like that, and instead
    # wants them in __MACOSX.
    app_zip_path = "#{app_path}.zip"
    sh("ditto -c -k --keepParent --sequesterRsrc #{app_path} #{app_zip_path}")
    sparkle_path = "#{build_path}/derived_data/SourcePackages/artifacts/sparkle/Sparkle"

    sparkle_secret_key = load_secret("SPARKLE_SECRET_KEY")

    sparkle_output = sh("echo '#{sparkle_secret_key}' | #{sparkle_path}/bin/sign_update #{app_zip_path} --ed-key-file -")
    app_config_path = File.absolute_path("../command.shared.xcconfig")
    version ||= sh("cat #{app_config_path} | grep APP_VERSION=").split("APP_VERSION=").last.strip

    puts "Notarization completed successfully. version: #{version}, app_path: #{app_path}"

    appcast_template_path = File.absolute_path("./appcast.template.xml")
    appcast_path = File.absolute_path("./appcast.xml")
    appcast_content = File.read(appcast_template_path)
    appcast_content.gsub!("REPLACE_SPARKLE_VERSION", version)
    appcast_content.gsub!("REPLACE_SPARKLE_DESCRIPTION", "Test description for the release\nnew line")
    appcast_content.gsub!("REPLACE_SPARKLE_SIGN_UPDATE", sparkle_output)
    File.write(appcast_path, appcast_content)

    readme_path = File.absolute_path("../../README.md")
    readme_content = File.read(readme_path)
    readme_content.gsub!(/(.*)v[0-9\.]+(.*<!-- version automatically updated during release -->)/, "\\1v#{version}\\2")
    File.write(readme_path, readme_content)

    { version: version, app_zip_path: app_zip_path, app_binary_path: app_binary_path, build_path: build_path, dmg_path: dmg_path, archive_path: archive_path }
  end

  desc "Build, archive, sign and publish the app for distribution"
  lane :distribute_release do |options|
    skip_symbols_upload = !options[:skip_symbols_upload].nil?
    skip_release_upload = !options[:skip_release_upload].nil?

    setup_ci if ENV["CI"]
    # Set APP_DISTRIBUTION_CHANNEL to stable for release builds
    app_config_path = File.absolute_path("../command.shared.xcconfig")
    sh("sed -i '' 's/APP_DISTRIBUTION_CHANNEL=.*/APP_DISTRIBUTION_CHANNEL=stable/' #{app_config_path}")

    original_version = sh("cat #{app_config_path} | grep APP_VERSION=").split("APP_VERSION=").last.strip
    version = find_available_version(original_version)

    # Update config file if version changed
    if version != original_version
      sh("sed -i '' 's/APP_VERSION=#{original_version}/APP_VERSION=#{version}/' #{app_config_path}")
    end

    ENV["UPLOAD_SOURCEMAP_TO_SENTRY"] = "true"
    ENV["SENTRY_AUTH_TOKEN"] = load_secret("SENTRY_AUTH_TOKEN")

    result = create_and_sign_release(version: version)
    version = result[:version]
    app_zip_path = result[:app_zip_path]
    dmg_path = result[:dmg_path]
    build_path = result[:build_path]
    app_binary_path = result[:app_binary_path]
    archive_path = result[:archive_path]

    unless skip_symbols_upload
      # Upload the dsym and relevant release info to Sentry/Bugsnag
      upload_release_symbols(version: version, app_binary_path: app_binary_path, archive_path: archive_path, build_path: build_path)
    end

    unless skip_release_upload
      aws_access_key_id = load_secret("AWS_ACCESS_KEY_ID")
      aws_secret = load_secret("AWS_SECRET_ACCESS_KEY")
      sh("AWS_ACCESS_KEY_ID='#{aws_access_key_id}' AWS_SECRET_ACCESS_KEY='#{aws_secret}' AWS_DEFAULT_REGION=us-east-1 aws s3 cp #{dmg_path} s3://cmd-releases/#{version}/cmd.dmg")
      puts("Release uploaded to https://cmd-releases.s3.us-east-1.amazonaws.com/#{version}/cmd.dmg/")

      gh_personal_access_token_cmd = load_secret("GH_WRITE_TOKEN")
      set_github_release(
        api_token: gh_personal_access_token_cmd,
        server_url: "https://api.github.com",
        repository_name: "getcmd-dev/cmd",
        name: "v#{version}",
        tag_name: "v#{version}",
        description: "v#{version}",
        commitish: "main",
        upload_assets: [dmg_path, app_zip_path],
      )

      current_branch = sh("git rev-parse --abbrev-ref HEAD").strip
      new_branch_name = "release-v#{version}"
      sh("git add ../..  && git commit -m 'Release v#{version}' --no-verify && git checkout -b #{new_branch_name} && git push origin #{new_branch_name} --no-verify")
      create_pull_request(
        api_token: gh_personal_access_token_cmd,
        title: "Release v#{version}",
        repo: "getcmd-dev/cmd",
        head: new_branch_name,
        base: "main",
      )
    end
    sh("git checkout #{current_branch}")
  end

  desc "Strip debug symbols from the binary and resign the app"
  lane :strip_debug_symbols_and_resign do |options|
    # NOTE: an alternative is to more simply use COPY_PHASE_STRIP / STRIP_INSTALLED_PRODUCT in the build settings.
    # Those seem to result in a larger binary, but might be less succeptible to break.
    app_binary_path = options.fetch(:app_binary_path, nil)
    app_path = options.fetch(:app_path, nil)
    build_path = options.fetch(:build_path, nil)
    certificate_name = options.fetch(:certificate_name, nil)
    certificate_sha1 = options.fetch(:certificate_sha1, nil)

    ## Extract the current entitlements before stripping
    entitlements_path = "#{build_path}/extracted_entitlements.plist"
    sh("codesign -d --entitlements :- '#{app_path}' > '#{entitlements_path}'")

    # Strip only debug info, preserve symbols needed for crash symbolication
    sh("strip -rSTx '#{app_binary_path}'")
    all_components = []
    Dir.glob("#{app_path}/**/*").each do |file|
      next unless File.file?(file) && !File.symlink?(file)

      file_type = begin
        silent_sh("file -b '#{file}'")
      rescue
        ""
      end
      next unless file_type.include?("Mach-O") && !file_type.include?("dSYM")

      puts "Stripping: #{file}"
      begin
        silent_sh("strip -rSTx '#{file}'")
      rescue
        puts "Warning: Could not strip #{file}"
      end

      depth = file.split("/").length
      all_components << { path: file, depth: depth }
    end

    ## Resign
    puts "Using certificate to resign the app after stripping DEBUG symbols: #{certificate_name} (#{certificate_sha1})"

    # Find all signable components and sort by depth (deepest first)
    all_components = []

    Dir.glob("#{app_path}/**/*.{framework,appex,bundle,xpc,app}").each do |component|
      # Skip the main app itself
      next if component == app_path

      depth = component.split("/").length
      all_components << { path: component, depth: depth }
    end

    # Also find standalone executables (like command-line tools) that were stripped
    Dir.glob("#{app_path}/Contents/Applications/**/*").each do |file|
      next unless File.file?(file) && !File.symlink?(file)

      file_type = begin
        silent_sh("file -b '#{file}'")
      rescue
        ""
      end

      # If it's a Mach-O executable and not already in our list, add it
      next unless file_type.include?("Mach-O") && !file_type.include?("dSYM") && !all_components.any? { |c| file.start_with?(c[:path]) }

      depth = file.split("/").length
      puts "adding macho file: #{file}"
      all_components << { path: file, depth: depth }
    end

    # Sort by depth (deepest first) then by path for consistency
    all_components.sort_by! { |c| [-c[:depth], c[:path]] }

    # Sign each component with its own entitlements (if any)
    all_components.each do |component|
      component_name = File.basename(component[:path])
      puts "Signing: #{component_name}"

      # Try to extract entitlements for ANY component type
      temp_entitlements = "#{build_path}/#{component_name.gsub(/[^\w.-]/, "_")}_entitlements.plist"
      begin
        silent_sh("codesign -d --entitlements :- '#{component[:path]}' > '#{temp_entitlements}' 2>/dev/null")
      rescue
        nil
      end

      # Sign with entitlements if they exist, without if they don't
      if File.exist?(temp_entitlements) && File.size(temp_entitlements) > 0
        silent_sh("codesign --force --sign #{certificate_sha1} --options runtime --timestamp --entitlements '#{temp_entitlements}' '#{component[:path]}'")
      else
        silent_sh("codesign --force --sign #{certificate_sha1} --options runtime --timestamp '#{component[:path]}'")
      end

      # Clean up
      silent_sh("rm -f '#{temp_entitlements}'")
    end

    # Finally sign the main app
    puts "Signing: #{app_path}"
    silent_sh("codesign --force --sign #{certificate_sha1} --options runtime --timestamp --entitlements '#{entitlements_path}' '#{app_path}'")
    silent_sh("rm '#{entitlements_path}'")
  end

  desc "Build, archive, sign and upload symbols (for CI artifacts)"
  lane :build_artifact do
    setup_ci if ENV["CI"]

    # Enable Sentry source map uploads
    ENV["UPLOAD_SOURCEMAP_TO_SENTRY"] = "true"
    ENV["SENTRY_AUTH_TOKEN"] = load_secret("SENTRY_AUTH_TOKEN")

    # Get current version without bumping
    app_config_path = File.absolute_path("../command.shared.xcconfig")
    version = sh("cat #{app_config_path} | grep APP_VERSION=").split("APP_VERSION=").last.strip

    # Build and sign the release
    result = create_and_sign_release(version: version)
    version = result[:version]
    app_binary_path = result[:app_binary_path]
    archive_path = result[:archive_path]
    build_path = result[:build_path]

    # Upload symbols without creating a Sentry release
    upload_release_symbols(version: version, app_binary_path: app_binary_path, archive_path: archive_path, build_path: build_path, skip_release_creation: true)

    puts "Build artifact completed. DMG available at: #{result[:dmg_path]}"
  end

  desc "Upload the new release to Sentry"
  lane :upload_release_symbols do |options|
    version = options.fetch(:version, nil)
    app_binary_path = options.fetch(:app_binary_path, nil)
    archive_path = options[:archive_path]
    skip_release_creation = options.fetch(:skip_release_creation, false)

    # Verify dSYM UUID matches binary
    binary_uuid = sh("dwarfdump --uuid '#{app_binary_path}' | awk '{print $2}'").strip
    dsym_uuid = sh("dwarfdump --uuid '#{archive_path}/dSYMs/cmd.app.dSYM' | awk '{print $2}'").strip
    raise "UUID mismatch between binary and dSYM. This will prevent traces from being symbolicated" unless binary_uuid == dsym_uuid

    unless skip_release_creation
      sentry_create_release(
        auth_token: load_secret("SENTRY_AUTH_TOKEN"),
        org_slug: "getcmd",
        project_slug: "cmd-mac-app",
        version: version,
        app_identifier: "dev.getcmd.command",
        finalize: true,
      )
    end

    sh("sentry-cli debug-files upload --auth-token #{load_secret("SENTRY_AUTH_TOKEN")} --org getcmd --project cmd-mac-app --include-sources --wait '#{archive_path}'")

    unless skip_release_creation
      sentry_set_commits(
        org_slug: "getcmd",
        project_slug: "cmd-mac-app",
        version: version,
        app_identifier: "dev.getcmd.command",
        auto: true,
        clear: false,
      )
    end
  end
end
