# Fastlane lanes for the Bramble iOS app (Capacitor):
#   fastlane ipa      -> distribution IPA to ~/Desktop, no upload (no API key needed)
#   fastlane beta     -> build + upload to TestFlight, auto-bumping the build number
#   fastlane beta_ci  -> the same, on a runner: match identities, no Xcode login session
#   fastlane certs    -> create/renew those identities into the private certificates repo
#   fastlane identities -> CI preflight: check the credentials and profiles, build nothing
#   fastlane metadata -> upload App Store text metadata (en-US); no binary, no screenshots
#
# Both run the Capacitor pre-chain first (web bundle + native FFI libs + cap sync) so
# the archive is always current. Signing is automatic + -allowProvisioningUpdates, which
# already registers the app + AutoFillProbe extension entitlements (app group + the
# shared keychain group). The App Store Connect key is age-encrypted and unwrapped per run by
# scripts/asc-api-key.ts (YubiKey PIN + touch); see docs/release-signing.md.

require "json"

default_platform(:ios)

REPO = `git rev-parse --show-toplevel`.strip
BUNDLE_ID = "app.bramble.mobile"
# The autofill credential provider ships inside the app and is signed separately, so every
# signing step here is two steps. Its entitlements (app group, shared keychain group) are why
# a release cannot simply sign the app target and be done.
PROBE_ID = "app.bramble.mobile.AutoFillProbe"
# From the Appfile rather than a second copy of the string.
TEAM_ID = CredentialsManager::AppfileConfig.try_fetch_value(:team_id)
# This folder is the REPO ROOT fastlane, not the iOS project's: fastlane's supply layout puts the
# Android listing at `<root>/fastlane/metadata/android/`, so it lives here and the iOS one keeps it
# company rather than sitting two directories deeper. Everything below is therefore addressed from
# the repo root.
PROJECT = "#{REPO}/packages/platform-mobile/ios/App/App.xcodeproj"
# deliver's metadata dir, nested in ios/ so the App Store listing sits beside the Android one
# (metadata/android/, where supply expects it). The two could share metadata/ directly -
# deliver skips `android` as a known supply dir - but it rejects any other non-locale child, so
# with this layout metadata_path MUST be passed explicitly: the default (metadata/) errors on `ios`.
#
# Built from REPO, NOT `__dir__`: inside a Fastfile `__dir__` evaluates to "." (fastlane
# instance_evals the file from a relative path), so these became "./metadata/ios" and
# "./screenshots". deliver then expanded them against the REPO ROOT rather than fastlane/,
# silently found zero locale folders, created the empty dirs, uploaded nothing, and reported
# success. Absolute paths, so there is nothing left to resolve.
METADATA = "#{REPO}/fastlane/metadata/ios"
SCREENSHOTS = "#{REPO}/fastlane/screenshots"
# Where gym drops the .ipa + .dSYM.zip. Without this it writes to the working directory, which
# since fastlane moved to the repo root means littering the root with build artifacts. This path
# is already gitignored (packages/platform-mobile/ios/.gitignore, `App/output`).
BUILD_OUT = "#{REPO}/packages/platform-mobile/ios/App/output"
# Force DerivedData (incl. the SPM checkout cache) onto the internal disk, at a stable
# repo-local (gitignored) path. Xcode's default here points at an external Transcend
# volume that EPERMs the SPM cache and breaks `build_app` (see
# packages/platform-mobile/docs/development.md quirks 3-6). /tmp avoided that but macOS
# prunes /tmp after ~3 days, corrupting the SPM checkout (.git kept, working tree gone);
# a repo-local path is internal, not auto-pruned, and never committed.
DERIVED = "#{REPO}/packages/platform-mobile/ios/DerivedData"

# Xcode's EXPORT step rewrites the build number unless told not to: `xcodebuild -help` documents
# manageAppVersionAndBuildNumber as "Defaults to YES", and gym doesn't set it. That silently shipped
# archive+1, so the pbxproj and the release commit said N while TestFlight received N+1 (caught by
# diffing the .xcarchive's Info.plist against the exported IPA's: 207275524 vs 207275525). Off, so
# the exported binary carries exactly the number that was archived.
#
# A fresh hash per call, not a constant: gym mutates what it's handed (it merges `method` in), so a
# frozen constant raises FrozenError during packaging and a shared mutable one leaks between lanes.
def stable_export_options
  { manageAppVersionAndBuildNumber: false }
end

# Build number = seconds since 2020. Strictly increasing and never collides, unlike
# `latest_testflight_build_number + 1`, which queries App Store Connect and lags while builds
# process -> it kept recomputing already-used numbers ("Redundant Binary Upload").
#
# `pnpm run release ios` precomputes the same value and passes it via BRAMBLE_IOS_BUILD so the
# release commit it makes names the build that actually lands on TestFlight (iOS cuts no tag).
# Unset is the one-off case: a bare `fastlane beta`, or a runner dispatched with the build input
# left blank, which is how a build off a branch reaches TestFlight without a version bump.
def next_build_number
  raw = ENV["BRAMBLE_IOS_BUILD"]
  return (Time.now.to_i - 1_577_836_800) if raw.nil? || raw.strip.empty?
  raw.to_i
end

# App Store Connect API key, shared by the TestFlight + metadata lanes.
#
# scripts/asc-api-key.ts unwraps it from the age + YubiKey blob (PIN + touch), or hands back
# ASC_KEY_ID / ASC_ISSUER_ID / ASC_KEY_CONTENT when those are already set, which is how CI passes
# it. Backticks rather than fastlane's `sh`, which echoes what it runs and what it prints: this
# output IS the private key. `key_content` rather than `key_filepath` for the same reason, so a
# decrypted .p8 never has to exist on disk.
def asc_api_key
  raw = `node #{REPO}/scripts/asc-api-key.ts`
  unless $?.success?
    UI.user_error!("no App Store Connect API key; see docs/release-signing.md (iOS section)")
  end
  creds = JSON.parse(raw)
  app_store_connect_api_key(
    key_id: creds.fetch("keyId"),
    issuer_id: creds.fetch("issuerId"),
    key_content: creds.fetch("key"),
  )
end

platform :ios do
  # Web bundle + shared Rust libs (installs ios/App/VaultCryptoFFI/) + Capacitor sync.
  # Absolute cd so it works regardless of fastlane's working directory.
  private_lane :prepare do
    sh("cd '#{REPO}' && pnpm core:build && pnpm ffi:build:ios && " \
       "cd packages/platform-mobile && npx cap sync ios")
  end

  desc "Build a distribution IPA to ~/Desktop (no upload; no API key needed)"
  lane :ipa do
    prepare
    build_app(
      project: PROJECT,
      scheme: "App",
      export_method: "app-store",
      derived_data_path: DERIVED,
      output_directory: File.expand_path("~/Desktop"),
      output_name: "Bramble-TestFlight.ipa",
      xcargs: "-allowProvisioningUpdates",
      export_options: stable_export_options,
    )
    UI.success("IPA at ~/Desktop/Bramble-TestFlight.ipa")
  end

  desc "Build + upload to TestFlight (monotonic timestamp build number)"
  lane :beta do
    key = asc_api_key
    prepare
    next_build = next_build_number
    # Pin CURRENT_PROJECT_VERSION at build time (both the app + extension consume it). `pnpm run
    # release ios` commits this same number to the pbxproj (mirroring the Android versionCode), so
    # the xcarg just guarantees the built binary matches what was passed in, and keeps a bare
    # `fastlane beta` working when nothing was committed. stable_export_options keeps export from
    # re-stamping it afterwards.
    build_app(
      project: PROJECT,
      scheme: "App",
      export_method: "app-store",
      derived_data_path: DERIVED,
      output_directory: BUILD_OUT,
      xcargs: "CURRENT_PROJECT_VERSION=#{next_build} -allowProvisioningUpdates",
      export_options: stable_export_options,
    )
    upload_to_testflight(api_key: key, skip_waiting_for_build_processing: true)
    UI.success("Uploaded build #{next_build} to TestFlight")
  end

  # readonly: a runner may USE the certificate and profiles, never create or renew them. Renewal is
  # a deliberate `fastlane certs` from the maintainer's machine, so an expiring profile surfaces as
  # a failed release rather than as a runner silently minting an identity against the team's
  # limited certificate slots.
  private_lane :fetch_identities do |options|
    # A throwaway keychain for the imported identity, unlocked for this job only. Without it the
    # runner's login keychain is locked and codesign fails with a prompt nobody can answer.
    setup_ci
    sync_code_signing(
      type: "appstore",
      readonly: true,
      api_key: options[:api_key],
      storage_mode: "git",
      git_url: ENV.fetch("MATCH_GIT_URL"),
      app_identifier: [BUNDLE_ID, PROBE_ID],
    )
  end

  desc "CI: check the credentials and signing identities, build nothing"
  lane :identities do
    key = asc_api_key
    fetch_identities(api_key: key)
    mapping = lane_context[SharedValues::MATCH_PROVISIONING_PROFILE_MAPPING] || {}
    [BUNDLE_ID, PROBE_ID].each do |bundle|
      UI.user_error!("no App Store profile for #{bundle}; run `fastlane certs`") unless mapping[bundle]
      UI.success("#{bundle} -> #{mapping[bundle]}")
    end

    # One real API call, because everything above this line is a git clone and a decrypt: they
    # prove the match secrets and say nothing about whether Apple accepts the key.
    latest = latest_testflight_build_number(api_key: key, app_identifier: BUNDLE_ID, initial_build_number: 0)
    UI.success("App Store Connect answered: latest TestFlight build is #{latest}")
  end

  desc "Create or renew the signing identities in the private certificates repo"
  lane :certs do
    # The writing half of what beta_ci consumes readonly, and deliberately a local command: this is
    # what mints a distribution certificate against the team's limited slots, and it needs the App
    # Manager key rather than the Developer-role one a runner should hold.
    sync_code_signing(
      type: "appstore",
      readonly: false,
      api_key: asc_api_key,
      storage_mode: "git",
      git_url: ENV.fetch("MATCH_GIT_URL"),
      app_identifier: [BUNDLE_ID, PROBE_ID],
    )
    UI.success("certificates and profiles are in #{ENV.fetch('MATCH_GIT_URL')}")
  end

  desc "CI: build + upload to TestFlight, signing with the match identities"
  lane :beta_ci do
    key = asc_api_key
    # Credentials first, THEN the twenty-minute pre-chain. Every way this job fails for a reason
    # that is not the code - an expired profile, a rotated token, a mistyped secret - fails here,
    # and waiting for a build first only makes the same error cost half an hour.
    fetch_identities(api_key: key)
    prepare

    # The project is set to automatic signing, which needs an Xcode account this runner does not
    # have. Both targets are flipped to the profiles match just fetched. This edits the pbxproj of
    # a throwaway checkout and is never committed; the local lanes are untouched.
    { "App" => BUNDLE_ID, "AutoFillProbe" => PROBE_ID }.each do |target, bundle|
      update_code_signing_settings(
        path: PROJECT,
        use_automatic_signing: false,
        targets: [target],
        bundle_identifier: bundle,
        team_id: TEAM_ID,
        code_sign_identity: "Apple Distribution",
        profile_name: lane_context[SharedValues::MATCH_PROVISIONING_PROFILE_MAPPING][bundle],
      )
    end

    next_build = next_build_number
    # App Store Connect closes a marketing version for new builds once it has shipped, so a one-off
    # build off a branch sitting at a released version is rejected on upload ("Invalid Pre-Release
    # Train"). BRAMBLE_IOS_VERSION overrides it for this archive only; a release leaves it unset
    # and uses what `release ios` committed to the pbxproj.
    override = ENV["BRAMBLE_IOS_VERSION"].to_s.strip
    marketing = override.empty? ? "" : " MARKETING_VERSION=#{override}"
    build_app(
      project: PROJECT,
      scheme: "App",
      export_method: "app-store",
      derived_data_path: DERIVED,
      output_directory: BUILD_OUT,
      xcargs: "CURRENT_PROJECT_VERSION=#{next_build}#{marketing}",
      # gym merges match's mapping into export_options on its own; passed explicitly so the
      # signing inputs are all visible in one place rather than arriving by convention.
      export_options: stable_export_options.merge(
        signingStyle: "manual",
        provisioningProfiles: lane_context[SharedValues::MATCH_PROVISIONING_PROFILE_MAPPING],
      ),
    )
    upload_to_testflight(api_key: key, skip_waiting_for_build_processing: true)
    UI.success("Uploaded build #{next_build}#{override.empty? ? '' : " (version #{override})"} to TestFlight")
  end

  desc "Upload App Store metadata (en-US text only; no binary, no screenshots)"
  lane :metadata do
    # Reads fastlane/metadata/ios/**. Fill in the real privacy + support
    # URLs (metadata/ios/en-US/*_url.txt) before running. App Review contact + notes are NOT
    # managed here (no review_information/ folder); set them in App Store Connect directly.
    # app_version tracks the project's MARKETING_VERSION, so deliver targets (and creates,
    # if missing) that App Store version - no need to edit this lane when the version bumps.
    #
    # Screenshots: uploaded only when screenshots/<locale>/ actually holds image files. When
    # it does, overwrite_screenshots replaces App Store Connect's set with the repo's, so any
    # changes get pushed; when the folder is empty we skip them so a run never wipes the
    # screenshots already on App Store Connect.
    shots = Dir.glob(File.join(SCREENSHOTS, "**", "*.{png,PNG,jpg,JPG,jpeg,JPEG}"))
    has_shots = !shots.empty?
    UI.message(
      has_shots ? "Found #{shots.count} screenshot(s); syncing the App Store set to match" \
                : "No screenshots in screenshots/; skipping screenshots"
    )
    # sync_screenshots reconciles App Store Connect against the local set (adds
    # missing, deletes extras) instead of overwrite_screenshots' delete-then-reupload.
    # overwrite only clears once at the start, so deliver's flaky upload/poll retries
    # (Apple 500s on "waiting for screenshots to appear") re-upload shots that already
    # landed and pile up duplicates; sync removes those on the next run. Still beta, so
    # it needs the env gate as well. See fastlane#16924 / #22193.
    ENV["FASTLANE_ENABLE_BETA_DELIVER_SYNC_SCREENSHOTS"] = "true" if has_shots
    upload_to_app_store(
      api_key: asc_api_key,
      app_identifier: BUNDLE_ID,
      app_version: get_version_number(xcodeproj: PROJECT, target: "App"),
      metadata_path: METADATA,
      screenshots_path: SCREENSHOTS,
      skip_binary_upload: true,
      skip_metadata: false,
      skip_screenshots: !has_shots,
      sync_screenshots: has_shots,
      force: true,                          # skip the HTML preview confirmation
      precheck_include_in_app_purchases: false,
      run_precheck_before_submit: false,
    )
  end
end
