require "open3"
require "shellwords"
require "fileutils"
require "tmpdir"
require "time"
require "digest"
require "base64"

default_platform(:ios)

PROJECT_ROOT = File.expand_path("..", __dir__)
APP_NAME = "HomeClaw"
BUNDLE_ID = "com.shahine.homeclaw"
ARCHIVE_PATH = File.join(PROJECT_ROOT, ".build/archives/#{APP_NAME}.xcarchive")
EXPORT_PATH = File.join(PROJECT_ROOT, ".build/export")

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

  # scrub() replaces invalid UTF-8 bytes — survives a stray non-UTF-8 byte in
  # unrelated values (e.g. a smart-quoted Unsplash key). Only ASC_*/HOMEKIT_*
  # are consumed here.
  File.binread(path).force_encoding("UTF-8").scrub.each_line do |line|
    line = line.strip
    next if line.empty? || line.start_with?("#")
    line = line.sub(/\Aexport /, "")
    next unless line.include?("=")
    key, _, val = line.partition("=")
    key = key.strip
    val = val.strip.gsub(/\A["']|["']\z/, "")
    ENV[key] = val if !ENV.key?(key) || ENV[key].to_s.empty?
  end
end

# Load project + global secret env files (matches archive.sh + asc-testflight.py).
load_env_file(File.join(PROJECT_ROOT, ".env.local"))
load_env_file(File.expand_path("~/.secrets.env"))

# The Xcode version this project builds with, from .xcode-version.
def pinned_xcode_version
  pin_file = File.join(PROJECT_ROOT, ".xcode-version")
  return nil unless File.exist?(pin_file)

  pin = File.read(pin_file).strip
  pin.empty? ? nil : pin
end

# Locate the installed Xcode.app reporting `version`. Betas and release builds
# live side by side, so match on CFBundleShortVersionString, not the app name.
def xcode_app_for_version(version)
  Dir["/Applications/Xcode*.app"].find do |app|
    installed = `defaults read #{Shellwords.escape(File.join(app, "Contents/Info.plist"))} CFBundleShortVersionString 2>/dev/null`.strip
    installed == version
  end
end

# Resolve the Xcode to archive with, and say which one it was.
#
# A machine with several Xcodes installed otherwise archives against whatever
# `xcode-select` points at, invisibly — a release built on the wrong toolchain
# is not something to discover from App Store Connect. Resolution order:
# DEVELOPER_DIR, XCODE_APP, .xcode-version, xcode-select.
def resolve_toolchain!
  if ENV["DEVELOPER_DIR"].to_s.empty? && !ENV["XCODE_APP"].to_s.empty?
    ENV["DEVELOPER_DIR"] = File.join(ENV["XCODE_APP"], "Contents/Developer")
  end

  pin = pinned_xcode_version
  if ENV["DEVELOPER_DIR"].to_s.empty? && pin
    app = xcode_app_for_version(pin)
    if app.nil?
      installed = Dir["/Applications/Xcode*.app"].map do |a|
        v = `defaults read #{Shellwords.escape(File.join(a, "Contents/Info.plist"))} CFBundleShortVersionString 2>/dev/null`.strip
        "#{a} (#{v.empty? ? '?' : v})"
      end
      UI.user_error!(
        "\.xcode-version pins Xcode #{pin}, but no installed Xcode reports that version.\n" \
        "Installed: #{installed.join(', ')}\n" \
        "Install it, or override for this run: XCODE_APP=/Applications/Xcode.app fastlane <lane>"
      )
    end
    ENV["DEVELOPER_DIR"] = File.join(app, "Contents/Developer")
  end

  dir = ENV["DEVELOPER_DIR"]
  UI.user_error!("DEVELOPER_DIR does not exist: #{dir}") if !dir.to_s.empty? && !Dir.exist?(dir)

  version = `xcodebuild -version 2>/dev/null`.lines.first.to_s.strip
  path = `xcode-select -p 2>/dev/null`.strip
  UI.message("Toolchain: #{version.empty? ? 'unknown' : version} (#{path})")
  UI.important("Toolchain #{version} does not match .xcode-version pin #{pin}") if pin && !version.include?(pin)
  version
end

# Concurrent xcodebuild runs against the same project can wedge the build
# service: a run sits at the toolchain probe producing nothing, which reads as a
# broken toolchain rather than contention. Fail fast instead of hanging a
# release. Scoped to this project; unrelated builds on the machine are allowed.
def ensure_no_concurrent_build!
  pids = `pgrep -f "xcodebuild.*#{APP_NAME}.xcodeproj" 2>/dev/null`.split.reject { |p| p == Process.pid.to_s }
  return if pids.empty?

  UI.user_error!(
    "Another xcodebuild is already running against #{APP_NAME}.xcodeproj (pid #{pids.first}). " \
    "Concurrent builds wedge the build service. Wait for it to finish, or kill #{pids.first}."
  )
end

def asc_api_key
  # Accept the ASC_* names or the canonical App Store Connect API names used
  # machine-wide (so a shell with ~/.secrets.env sourced works without remapping).
  key_id = ENV["ASC_KEY_ID"] || ENV["APP_STORE_CONNECT_API_KEY_ID"]
  issuer_id = ENV["ASC_ISSUER_ID"] || ENV["APP_STORE_CONNECT_API_ISSUER_ID"]
  key_path = ENV["ASC_KEY_PATH"] || ENV["APP_STORE_CONNECT_API_KEY_PATH"]
  key_path = File.expand_path("~/.private_keys/AuthKey_#{key_id}.p8") if key_path.nil? || key_path.empty?

  UI.user_error!("Missing ASC key id (ASC_KEY_ID / APP_STORE_CONNECT_API_KEY_ID). Set in ~/.secrets.env.") if key_id.nil? || key_id.empty?
  UI.user_error!("Missing ASC issuer id (ASC_ISSUER_ID / APP_STORE_CONNECT_API_ISSUER_ID). Set in ~/.secrets.env.") if issuer_id.nil? || issuer_id.empty?
  UI.user_error!("ASC API key not found at #{key_path}.") unless File.exist?(key_path)

  app_store_connect_api_key(
    key_id: key_id,
    issuer_id: issuer_id,
    key_filepath: key_path,
    duration: 1200,
    in_house: false
  )
end

def team_id!
  # Fall back to the canonical APPLE_TEAM_ID from machine-wide secrets.
  team = ENV["HOMEKIT_TEAM_ID"] || ENV["APPLE_TEAM_ID"]
  if team.nil? || team.empty? || team == "YOUR_TEAM_ID"
    UI.user_error!("No Apple Developer Team ID. Set HOMEKIT_TEAM_ID or APPLE_TEAM_ID in .env.local / ~/.secrets.env.")
  end
  team
end

# Marketing version from latest `v*` tag, stripping the `+build` suffix
# (release tags are `v{version}+{build}` and Apple rejects extra components).
def marketing_version
  tag = `git -C #{Shellwords.escape(PROJECT_ROOT)} describe --tags --abbrev=0 --match 'v*' 2>/dev/null`.strip
  tag = "v0.0.1" if tag.empty?
  tag.delete_prefix("v").split("+").first
end

# Build number: read `.build-number` and return file + 1.
#
# The canonical write happens in `project.yml`'s preBuildScript during Release
# archive — that script reads the same file, increments, writes back to
# `.build-number`, and updates Info.plist via PlistBuddy. Both sides compute
# the same value from the same source, but PlistBuddy wins for the binary's
# CFBundleVersion. We pass `CURRENT_PROJECT_VERSION` to xcodebuild for build
# settings parity; the prebuild script is the source of truth on disk.
#
# Falls back to `git rev-list --count HEAD` if `.build-number` is absent.
def next_build_number
  bn_file = File.join(PROJECT_ROOT, ".build-number")
  if File.exist?(bn_file)
    File.read(bn_file).strip.to_i + 1
  else
    `git -C #{Shellwords.escape(PROJECT_ROOT)} rev-list --count HEAD`.strip.to_i
  end
end

# Verify HomeKit entitlement is present in the source entitlements file —
# without it HMHomeManager silently returns zero homes.
def verify_homekit_entitlement!
  ents = File.join(PROJECT_ROOT, "Resources/HomeClaw.entitlements")
  unless File.exist?(ents) && File.read(ents).include?("com.apple.developer.homekit")
    UI.user_error!("HomeKit entitlement missing from #{ents}.")
  end
end

# Verify bundled artifacts after archive (CLI binary, AppKit bridge, MCP server,
# OpenClaw plugin, HomeKit entitlement on the signed app).
def verify_bundle!
  app_path = File.join(ARCHIVE_PATH, "Products/Applications/#{APP_NAME}.app")
  return unless File.directory?(app_path)

  checks = {
    "Contents/MacOS/HomeClaw" => :file,
    "Contents/MacOS/homeclaw-cli" => :file,
    "Contents/Resources/macOSBridge.bundle" => :dir,
    "Contents/Resources/mcp-server.js" => :file,
    "Contents/Resources/openclaw" => :dir
  }

  UI.message("Bundle contents:")
  checks.each do |rel, kind|
    full = File.join(app_path, rel)
    ok = kind == :file ? File.file?(full) : File.directory?(full)
    UI.success("  ✓ #{rel}") if ok
    UI.error("  ✗ #{rel} (missing)") unless ok
  end

  signed_ents, _, _ = Open3.capture3("codesign", "-d", "--entitlements", ":-", app_path)
  if signed_ents.include?("com.apple.developer.homekit")
    UI.success("  ✓ HomeKit entitlement present")
  else
    UI.error("  ✗ HomeKit entitlement missing on signed app!")
  end
end

# Resolve the dynamic external beta group name (uses the first external group,
# matching asc-testflight.py's behavior).
def first_external_group_name(api_key)
  require "spaceship"
  app = Spaceship::ConnectAPI::App.find(BUNDLE_ID)
  UI.user_error!("App not found: #{BUNDLE_ID}") unless app
  groups = app.get_beta_groups
  external = groups.find { |g| !g.is_internal_group }
  UI.user_error!("No external beta groups configured. Create one in App Store Connect.") unless external
  external.name
end

# xcodebuild authentication args for cloud signing. Without these,
# -allowProvisioningUpdates depends on an Apple ID session inside Xcode —
# which silently vanishes on Xcode updates and then exportArchive fails with
# "No Accounts" / "No signing certificate 'Mac Installer Distribution' found"
# (it can't refresh the profile or mint the installer cert). Passing the ASC
# API key lets xcodebuild manage certs/profiles headlessly.
def xcodebuild_auth_args
  key_id = ENV["ASC_KEY_ID"] || ENV["APP_STORE_CONNECT_API_KEY_ID"]
  issuer_id = ENV["ASC_ISSUER_ID"] || ENV["APP_STORE_CONNECT_API_ISSUER_ID"]
  key_path = ENV["ASC_KEY_PATH"] || ENV["APP_STORE_CONNECT_API_KEY_PATH"]
  key_path = File.expand_path("~/.private_keys/AuthKey_#{key_id}.p8") if (key_path.nil? || key_path.empty?) && key_id
  return "" if key_id.nil? || issuer_id.nil? || key_path.nil? || !File.exist?(File.expand_path(key_path))

  " -authenticationKeyPath #{Shellwords.escape(File.expand_path(key_path))}" \
    " -authenticationKeyID #{Shellwords.escape(key_id)}" \
    " -authenticationKeyIssuerID #{Shellwords.escape(issuer_id)}"
end

# Resolve the App Store provisioning profile to export with.
#
# The name can't be hardcoded. `sigh` refuses to reuse a profile whose signing
# certificate isn't installed locally, and when the canonical name is already
# taken it creates a timestamped one ("<name> 1788724911") instead of replacing
# it. A machine that didn't mint the original profile therefore ends up with a
# working profile under a name the Fastfile doesn't know.
#
# Pick the newest installed profile for this bundle id whose name starts with
# the canonical prefix and that hasn't expired. Purely local -- no network.
APPSTORE_PROFILE_PREFIX = "com.shahine.homeclaw AppStore"

# SHA-1 fingerprints of every code-signing identity in the local keychain.
def local_certificate_fingerprints
  @local_certificate_fingerprints ||= begin
    out, _, _ = Open3.capture3("security", "find-identity", "-v", "-p", "codesigning")
    out.scan(/\b([0-9A-F]{40})\b/).flatten.uniq
  end
end

# True when at least one certificate embedded in the profile is installed
# locally. DeveloperCertificates holds base64 DER blobs; their SHA-1 is the
# same fingerprint `security find-identity` prints.
def profile_certs_available?(plist)
  section = plist[%r{<key>DeveloperCertificates</key>\s*<array>(.*?)</array>}m, 1]
  return false if section.nil?

  section.scan(%r{<data>(.*?)</data>}m).flatten.any? do |b64|
    der = Base64.decode64(b64)
    fingerprint = Digest::SHA1.hexdigest(der).upcase
    local_certificate_fingerprints.include?(fingerprint)
  end
end

def appstore_profile_name
  return ENV["HOMECLAW_APPSTORE_PROFILE"] unless ENV["HOMECLAW_APPSTORE_PROFILE"].to_s.empty?

  dir = File.expand_path("~/Library/Developer/Xcode/UserData/Provisioning Profiles")
  best = nil

  Dir[File.join(dir, "*.provisionprofile")].each do |path|
    plist, _, status = Open3.capture3("security", "cms", "-D", "-i", path)
    next unless status.success?

    name = plist[%r{<key>Name</key>\s*<string>([^<]*)</string>}, 1]
    next unless name&.start_with?(APPSTORE_PROFILE_PREFIX)

    expiry = plist[%r{<key>ExpirationDate</key>\s*<date>([^<]*)</date>}, 1]
    next if expiry.nil?
    exp = Time.parse(expiry) rescue nil
    next if exp.nil? || exp < Time.now

    # Expiry alone isn't enough. A profile minted on another Mac embeds that
    # machine's distribution certificate; picking it because it happens to
    # expire later reproduces the exact certificate mismatch this resolver
    # exists to avoid. Require that one of the profile's embedded certs is
    # actually installed here.
    next unless profile_certs_available?(plist)

    best = [name, exp] if best.nil? || exp > best[1]
  end

  UI.user_error!(
    "No unexpired provisioning profile starting with #{APPSTORE_PROFILE_PREFIX.inspect} is installed.\n" \
    "(A profile whose signing certificate isn't in this keychain is skipped.)\n" \
    "Create one: fastlane run sigh app_identifier:com.shahine.homeclaw platform:catalyst force:true\n" \
    "Or point at one explicitly: HOMECLAW_APPSTORE_PROFILE='<name>' fastlane <lane>"
  ) if best.nil?

  UI.message("Provisioning profile: #{best[0]}")
  best[0]
end

def archive_and_export(team:, version:, build_number:, export: false)
  rm_rf = ->(p) { FileUtils.rm_rf(p) }
  rm_rf.call(ARCHIVE_PATH)
  rm_rf.call(EXPORT_PATH) if export

  # Auth args go in xcargs ONLY: gym appends both xcargs and export_xcargs to
  # the exportArchive invocation, and xcodebuild rejects repeated
  # -authenticationKeyPath ("may only be provided once", exit 64).
  #
  # Export uses MANUAL signing: refreshing the Xcode-managed "Team Store"
  # profile is a cloud-signing operation the ASC API key isn't permitted to do
  # ("Cloud signing permission error"). The "com.shahine.homeclaw AppStore"
  # profile is created/renewed via `fastlane run sigh app_identifier:com.shahine.homeclaw
  # platform:catalyst force:true` (plain ASC API — allowed), and the installer
  # cert via `fastlane run cert type:mac_installer_distribution`.
  build_app(
    skip_package_ipa: !export,
    xcargs: "DEVELOPMENT_TEAM=#{team} MARKETING_VERSION=#{version} CURRENT_PROJECT_VERSION=#{build_number} ONLY_ACTIVE_ARCH=NO -allowProvisioningUpdates#{xcodebuild_auth_args}",
    export_xcargs: "-allowProvisioningUpdates",
    export_options: {
      method: "app-store",
      signingStyle: "manual",
      signingCertificate: "Apple Distribution",
      installerSigningCertificate: "3rd Party Mac Developer Installer",
      provisioningProfiles: { BUNDLE_ID => appstore_profile_name },
    }
  )

  verify_bundle!
end

platform :ios do
  # Pre-archive prep: regenerate Xcode project + build the Node.js MCP server.
  private_lane :prepare do
    UI.user_error!("xcodegen not installed. brew install xcodegen") unless system("command -v xcodegen >/dev/null 2>&1")

    resolve_toolchain!
    ensure_no_concurrent_build!

    UI.message("Generating Xcode project...")
    sh("cd #{Shellwords.escape(PROJECT_ROOT)} && xcodegen generate --use-cache", log: false)

    UI.message("Building MCP server...")
    sh("cd #{Shellwords.escape(PROJECT_ROOT)} && npm run build:mcp", log: false)

    verify_homekit_entitlement!
  end

  desc "Validate App Store Connect API key auth"
  lane :auth_check do
    asc_api_key
    UI.success("App Store Connect API auth OK.")
  end

  desc "Build a release .xcarchive (no upload)"
  lane :archive do
    prepare
    team = team_id!
    version = marketing_version
    build_number = next_build_number

    UI.message("Archiving #{APP_NAME} v#{version} build #{build_number}")
    archive_and_export(team: team, version: version, build_number: build_number, export: false)

    UI.success("Archive: #{ARCHIVE_PATH}")
    UI.success("Open in Organizer: open '#{ARCHIVE_PATH}'")
  end

  desc "Build + upload to App Store Connect (no external submission)"
  lane :upload do
    api_key = asc_api_key
    prepare
    team = team_id!
    version = marketing_version
    build_number = next_build_number

    UI.message("Archiving + uploading #{APP_NAME} v#{version} build #{build_number}")
    archive_and_export(team: team, version: version, build_number: build_number, export: true)

    upload_to_testflight(
      api_key: api_key,
      ipa: lane_context[SharedValues::IPA_OUTPUT_PATH],
      skip_waiting_for_build_processing: true,
      skip_submission: true
    )

    UI.success("Build #{build_number} uploaded. Check https://appstoreconnect.apple.com/apps/6759682551/testflight")
  end

  desc "Build + upload + submit to external TestFlight (full release loop)"
  lane :beta do |options|
    api_key = asc_api_key
    prepare
    team = team_id!
    version = marketing_version
    build_number = next_build_number

    notes = resolve_notes(options)

    UI.message("Releasing #{APP_NAME} v#{version} build #{build_number} to external TestFlight")
    archive_and_export(team: team, version: version, build_number: build_number, export: true)

    group_name = first_external_group_name(api_key)

    upload_to_testflight(
      api_key: api_key,
      ipa: lane_context[SharedValues::IPA_OUTPUT_PATH],
      changelog: notes,
      groups: [group_name],
      distribute_external: true,
      notify_external_testers: true,
      reject_build_waiting_for_review: true,
      wait_processing_interval: 30,
      wait_for_uploaded_build: true
    )

    UI.success("Build #{build_number} submitted to external group '#{group_name}'.")
  end

  desc "Re-submit an already-uploaded build to external TestFlight (recovery)"
  lane :submit_only do |options|
    api_key = asc_api_key
    build_number = options[:build] || ENV["BUILD"]
    UI.user_error!("Pass build:NNN (e.g. fastlane submit_only build:143)") unless build_number

    notes = resolve_notes(options)
    group_name = first_external_group_name(api_key)

    upload_to_testflight(
      api_key: api_key,
      app_identifier: BUNDLE_ID,
      build_number: build_number.to_s,
      changelog: notes,
      groups: [group_name],
      distribute_external: true,
      notify_external_testers: true,
      # Without distribute_only: true, pilot insists on an ipa/pkg upload —
      # errors with "No ipa or pkg file given" even when app_identifier +
      # build_number are provided. distribute_only tells pilot to work on the
      # already-uploaded build.
      distribute_only: true,
      # Mac Catalyst — the Gymfile's catalyst_platform doesn't apply here
      # (no build step), and pilot otherwise prompts interactively for the
      # target platform and crashes in non-interactive mode.
      app_platform: "osx",
      # The build is already uploaded and processed — pilot's
      # wait_for_build_processing loop hangs indefinitely even on a VALID
      # build when combined with distribute_only, so skip it.
      skip_waiting_for_build_processing: true
    )

    UI.success("Build #{build_number} submitted to '#{group_name}'.")
  end

  desc "Capture App Store screenshots via XCUITest in demo mode"
  # Runs HomeClawUITests/ScreenshotTests with `--ui-test-demo`, then uses
  # xcparse to extract XCTAttachments from the .xcresult bundle into
  # fastlane/screenshots-raw/en-US/. After this, run
  # `scripts/frame_screenshots.sh` to composite framed marketing versions
  # into fastlane/screenshots/en-US/ (which is what `upload_screenshots`
  # actually uploads).
  #
  # TODO: menu-bar dropdown screenshot. The dropdown is NSMenu (AppKit), not
  # SwiftUI, so it can't be rendered via ImageRenderer. Follow-up options:
  #   (a) build a SwiftUI mockup of the dropdown and ImageRenderer it here
  #   (b) `screencapture -x` of the running demo-mode app (manual click)
  lane :screenshots do
    prepare
    team = team_id!

    out_dir = File.join(PROJECT_ROOT, "fastlane/screenshots-raw/en-US")
    FileUtils.mkdir_p(out_dir)
    Dir.glob(File.join(out_dir, "*.png")).each { |f| File.delete(f) }

    result_path = File.join(PROJECT_ROOT, ".build/screenshots.xcresult")
    FileUtils.rm_rf(result_path)

    run_tests(
      project: "HomeClaw.xcodeproj",
      scheme: "HomeClawUITests",
      destination: "platform=macOS,variant=Mac Catalyst",
      result_bundle_path: result_path,
      code_coverage: false,
      xcargs: "DEVELOPMENT_TEAM=#{team} -allowProvisioningUpdates"
    )

    unless system("command -v xcparse >/dev/null 2>&1")
      UI.user_error!("xcparse not installed. Run: brew install chargepoint/xcparse/xcparse")
    end

    sh("xcparse screenshots #{Shellwords.escape(result_path)} #{Shellwords.escape(out_dir)}")

    # xcparse emits filenames like `01_Onboarding_0_<UUID>.png`. ASC's deliver
    # action sorts/uploads by filename, so strip the trailing `_<digits>_<UUID>`
    # to match the attachment name we set in ScreenshotTests.
    Dir.glob(File.join(out_dir, "*.png")).each do |path|
      base = File.basename(path, ".png")
      cleaned = base.sub(/_\d+_[A-F0-9-]+$/i, "")
      next if cleaned == base
      target = File.join(File.dirname(path), "#{cleaned}.png")
      if File.exist?(target) && target != path
        UI.user_error!("Two test attachments would clean to the same filename: #{File.basename(target)}. Rename one of the XCTAttachment names in ScreenshotTests.")
      end
      File.rename(path, target)
    end

    pngs = Dir.glob(File.join(out_dir, "*.png")).sort
    UI.success("Captured #{pngs.length} screenshots → #{out_dir}")
    pngs.each { |p| UI.message("  • #{File.basename(p)}") }
  end

  desc "Upload screenshots from fastlane/screenshots/ to App Store Connect"
  # HomeClaw is Mac Catalyst — screenshots target the Mac App Store listing,
  # not iOS. Without `platform: 'osx'` deliver hits the iOS listing and
  # errors with "Could not find a version to edit for app … for 'IOS'".
  lane :upload_screenshots do
    api_key = asc_api_key
    deliver(
      api_key: api_key,
      app_identifier: BUNDLE_ID,
      platform: "osx",
      skip_binary_upload: true,
      skip_metadata: true,
      skip_screenshots: false,
      # Replace ASC's existing screenshot set instead of appending.
      # Deliver's default is `overwrite_screenshots: false`, which
      # duplicates every screenshot against the new version.
      overwrite_screenshots: true,
      force: true,
      run_precheck_before_submit: false
    )
  end

  desc "Upload App Store listing metadata (description, keywords, URLs, etc.)"
  # Pushes everything in `fastlane/metadata/` to ASC's Mac App Store listing.
  # Skips binary + screenshots — use `upload_screenshots` or `beta` for those.
  lane :upload_metadata do
    api_key = asc_api_key
    deliver(
      api_key: api_key,
      app_identifier: BUNDLE_ID,
      platform: "osx",
      skip_binary_upload: true,
      skip_metadata: false,
      skip_screenshots: true,
      force: true,
      run_precheck_before_submit: false
    )
  end

  desc "Show TestFlight status for the latest (or specified) build"
  lane :status do |options|
    asc_api_key
    require "spaceship"
    app = Spaceship::ConnectAPI::App.find(BUNDLE_ID)
    UI.user_error!("App not found: #{BUNDLE_ID}") unless app

    bn = options[:build] || ENV["BUILD"]
    filter = { app: app.id }
    filter[:version] = bn.to_s if bn
    # `includes: "buildBetaDetail"` sideloads internal/external states in one call
    # so this works even for freshly uploaded builds where the detail isn't
    # cached on the Build object yet.
    builds = Spaceship::ConnectAPI.get_builds(
      filter: filter,
      sort: "-uploadedDate",
      limit: bn ? 5 : 1,
      includes: "buildBetaDetail"
    ).to_models
    UI.user_error!("No build found#{bn ? " (#{bn})" : ''}") if builds.empty?

    b = builds.first
    detail = b.build_beta_detail
    UI.message("Build #{b.version}")
    UI.message("  Processing: #{b.processing_state}")
    UI.message("  Uploaded:   #{b.uploaded_date}")
    UI.message("  Internal:   #{detail&.internal_build_state || '?'}")
    UI.message("  External:   #{detail&.external_build_state || '?'}")
  end

  desc "Full Mac App Store release: build + upload + create version + attach build + push screenshots/metadata + submit for review"
  # End-to-end flow for shipping a new App Store version. Reads marketing
  # version from the latest `v*` tag (strip `v` and `+build`), increments
  # build number from `.build-number`. After upload it waits inline for
  # ASC to finish processing the binary, then calls `deliver` to create
  # the App Store version, attach the build, push screenshots + metadata
  # + "What's New", and submit for review.
  #
  # The "What's New" text is required — pass via `notes_file:` /  `notes:`
  # CLI flag or `NOTES_FILE` / `NOTES` env var.
  #
  # `automatic_release: false` so the version stays in "Pending Developer
  # Release" after approval — you ship the green button when ready.
  lane :release do |options|
    api_key = asc_api_key
    prepare
    team = team_id!
    version = marketing_version
    build_number = next_build_number

    notes = resolve_notes(options)
    if notes.nil? || notes.strip.empty?
      UI.user_error!("Missing 'What's New' notes. Pass `notes_file:<path>` or `notes:<text>`.")
    end

    UI.message("Releasing #{APP_NAME} v#{version} build #{build_number} to the App Store")
    archive_and_export(team: team, version: version, build_number: build_number, export: true)

    # Upload + wait for processing so deliver can attach the build inline.
    # `skip_submission: true` keeps it out of the external TestFlight beta
    # queue — this lane is for App Store release, not beta distribution.
    upload_to_testflight(
      api_key: api_key,
      ipa: lane_context[SharedValues::IPA_OUTPUT_PATH],
      skip_waiting_for_build_processing: false,
      skip_submission: true
    )

    deliver(
      api_key: api_key,
      app_identifier: BUNDLE_ID,
      platform: "osx",
      app_version: version,
      build_number: build_number.to_s,
      release_notes: { "en-US" => notes },
      skip_binary_upload: true,
      skip_screenshots: false,
      # Replace ASC's existing screenshot set instead of appending.
      # Deliver's default is `overwrite_screenshots: false`, which
      # duplicates every screenshot against the new version.
      overwrite_screenshots: true,
      skip_metadata: false,
      force: true,
      submit_for_review: true,
      automatic_release: false,
      run_precheck_before_submit: false,
      submission_information: {
        add_id_info_uses_idfa: false,
        export_compliance_uses_encryption: false
      }
    )

    UI.success("v#{version}+#{build_number} submitted for App Store review")
    UI.success("Track at https://appstoreconnect.apple.com/apps/6759682551/distribution")
  end

  desc "Submit an already-uploaded build to App Store review (no rebuild)"
  # Use when the binary is already on App Store Connect (e.g. an existing
  # TestFlight build) and you want to ship that exact build to the store
  # without archiving a fresh one. Pass `build:<n>` (defaults to the current
  # `.build-number`). Marketing version comes from the latest `v*` tag.
  # Requires "What's New" notes via notes_file:/notes:/NOTES_FILE/NOTES.
  #
  # Both listing-asset pushes are opt-out, because they overwrite what's live:
  #   `skip_screenshots:true` — leave the live screenshot set alone. Use this
  #     when `fastlane/screenshots/` is stale relative to the shipping build;
  #     the default replaces the live set wholesale (overwrite_screenshots).
  #   `skip_metadata:true` — leave the live description/keywords/subtitle/URLs
  #     alone and upload ONLY the "What's New" text. Use this when the repo's
  #     `fastlane/metadata/` is older than the live listing, or the listing has
  #     been hand-edited in ASC since the last push.
  #
  # `auto_release:true` releases the version as soon as Apple approves it.
  # Default is manual, which parks it in "Pending Developer Release".
  lane :release_build do |options|
    api_key = asc_api_key
    version = marketing_version
    build_number = (options[:build] || File.read(File.join(PROJECT_ROOT, ".build-number")).strip).to_s

    notes = resolve_notes(options)
    if notes.nil? || notes.strip.empty?
      UI.user_error!("Missing 'What's New' notes. Pass `notes_file:<path>` or `notes:<text>`.")
    end

    skip_screenshots = truthy(options[:skip_screenshots])
    notes_only = truthy(options[:skip_metadata])
    auto_release = truthy(options[:auto_release])

    UI.message("Submitting existing #{APP_NAME} v#{version} build #{build_number} to the App Store")
    UI.important("Screenshots: leaving the live set untouched") if skip_screenshots
    UI.important("Metadata: uploading release notes only, leaving the live listing untouched") if notes_only
    UI.important("Release: automatic on approval") if auto_release

    deliver(
      api_key: api_key,
      app_identifier: BUNDLE_ID,
      platform: "osx",
      app_version: version,
      build_number: build_number,
      release_notes: { "en-US" => notes },
      skip_binary_upload: true,
      skip_screenshots: skip_screenshots,
      overwrite_screenshots: !skip_screenshots,
      # Never `skip_metadata: true` here — that would drop the required "What's
      # New" too. Narrowing `metadata_path` to a notes-only scratch dir is what
      # actually protects the rest of the live listing.
      skip_metadata: false,
      metadata_path: notes_only ? notes_only_metadata_path(notes) : nil,
      force: true,
      submit_for_review: true,
      # `false` leaves the version in "Pending Developer Release" after
      # approval; `auto_release:true` ships it the moment Apple approves.
      automatic_release: auto_release,
      run_precheck_before_submit: false,
      submission_information: {
        add_id_info_uses_idfa: false,
        export_compliance_uses_encryption: false
      }
    )

    UI.success("v#{version}+#{build_number} submitted for App Store review")
    UI.success("Track at https://appstoreconnect.apple.com/apps/6759682551/distribution")
  end
end

# Resolve test notes from --notes_file, --notes, or NOTES_FILE/NOTES env vars.
def resolve_notes(options)
  file = options[:notes_file] || ENV["NOTES_FILE"]
  return File.read(file).strip if file && File.exist?(file)
  options[:notes] || ENV["NOTES"]
end

# Lane options arrive as strings from the CLI (`skip_metadata:true`) but as real
# booleans when a lane is called from Ruby, so coerce both. Anything unset is
# false — flags must be opted into explicitly.
def truthy(value)
  return false if value.nil?
  return value if value == true || value == false
  %w[true 1 yes y].include?(value.to_s.strip.downcase)
end

# Build a scratch metadata directory containing ONLY release notes.
#
# `deliver`'s `skip_metadata: true` skips *all* metadata — including the
# "What's New" text, which App Store Connect requires on an update. So to leave
# the live listing (description, keywords, subtitle, URLs) untouched while still
# setting release notes, we point deliver at a directory holding just
# `<locale>/release_notes.txt`. Deliver only uploads fields whose files exist,
# so every other field on the live listing is left alone.
def notes_only_metadata_path(notes, locale: "en-US")
  dir = File.join(Dir.tmpdir, "homeclaw-deliver-metadata")
  FileUtils.rm_rf(dir)
  FileUtils.mkdir_p(File.join(dir, locale))
  File.write(File.join(dir, locale, "release_notes.txt"), notes)
  dir
end
