require "fileutils"
require "net/http"
require "socket"
require "tmpdir"
require "shellwords"

def omnigent_available_port
  loop do
    server = TCPServer.new("127.0.0.1", 0)
    port = server.addr[1]
    server.close
    return port unless port == 6767
  end
end

def omnigent_start_process(label, env, command, chdir:, log_path:)
  FileUtils.mkdir_p(File.dirname(log_path))
  UI.message("Starting #{label} on a dedicated screenshot port")
  pid = Process.spawn(
    env,
    *command,
    chdir: chdir,
    out: [log_path, "w"],
    err: [:child, :out],
    pgroup: true
  )
  Process.detach(pid)
  pid
end

def omnigent_run_command(label, command, chdir:)
  UI.message(label)
  UI.command(command.join(" "))
  success = system(*command, chdir: chdir)
  UI.user_error!("#{label} failed") unless success
end

def omnigent_fetch_env(*names)
  names.each do |name|
    value = ENV[name]
    return value unless value.to_s.strip.empty?
  end

  UI.user_error!("Set one of: #{names.join(", ")}")
end

def omnigent_stop_process(pid)
  return if pid.nil?

  Process.kill("TERM", -pid)
  deadline = Time.now + 10
  while Time.now < deadline
    begin
      Process.kill(0, pid)
    rescue Errno::ESRCH
      return
    end
    sleep 0.2
  end
  Process.kill("KILL", -pid)
rescue Errno::ESRCH
  nil
end

def omnigent_wait_for_http(url, timeout_seconds:, log_path:)
  uri = URI(url)
  deadline = Time.now + timeout_seconds
  last_error = nil

  while Time.now < deadline
    begin
      response = Net::HTTP.start(uri.host, uri.port, open_timeout: 2, read_timeout: 2) do |http|
        http.get(uri.request_uri)
      end
      return if response.code.to_i < 500
    rescue StandardError => e
      last_error = e
    end
    sleep 1
  end

  if File.exist?(log_path)
    UI.error("Last server log lines:\n#{File.readlines(log_path).last(80).join}")
  end
  UI.user_error!("Timed out waiting for #{url}: #{last_error}")
end

default_platform(:ios)

# Builds are signed with the team's distribution cert via Xcode automatic
# signing (-allowProvisioningUpdates). Upload + provisioning use an App Store
# Connect API key supplied through env vars — see fastlane/.env.example.

# Two distributions ship from the same target and sources. Each is a distinct
# app record (distinct bundle ID) with its own TestFlight build counter, so both
# can be installed side by side. The bundle ID and display name are injected as
# xcodebuild setting overrides at archive time — nothing in the repo changes.
#   - public:   the App Store app.
#   - internal: a private (custom/unlisted App Store) build for Databricks.
FLAVORS = {
  public: {
    bundle_id: "ai.omnigent.ios",
    display_name: "Omnigent",
  },
  internal: {
    bundle_id: "ai.omnigent.ios.internal",
    display_name: "Omnigent (Databricks)",
  },
}.freeze

platform :ios do
  desc "Run the OmnigentTests unit tests"
  lane :tests do
    run_tests(
      scheme: "Omnigent",
      configuration: "Debug",
      destination: "platform=iOS Simulator,name=iPhone 17 Pro Max,OS=26.5",
      skip_detect_devices: true,
      skip_testing: ["OmnigentUITests"]
    )
  end

  desc "Generate App Store screenshots with fastlane snapshot"
  lane :screenshots do
    repo_root = File.expand_path("../../..", __dir__)
    web_dir = File.join(repo_root, "web")
    static_index = File.join(repo_root, "omnigent/server/static/web-ui/index.html")
    unless File.executable?(File.join(web_dir, "node_modules/.bin/tsc"))
      omnigent_run_command(
        "Installing web UI dependencies",
        ["npm", "--prefix", web_dir, "install", "--package-lock=false", "--no-audit", "--no-fund"],
        chdir: repo_root
      )
    end
    omnigent_run_command(
      "Building current web UI for screenshots",
      ["npm", "--prefix", web_dir, "run", "build"],
      chdir: repo_root
    )
    UI.user_error!("Web UI build did not produce #{static_index}") unless File.file?(static_index)

    port = ENV.fetch("OMNIGENT_SCREENSHOT_SERVER_PORT", omnigent_available_port.to_s)
    UI.user_error!("OMNIGENT_SCREENSHOT_SERVER_PORT must not be 6767") if port.to_s == "6767"

    screenshot_root = File.join(Dir.tmpdir, "omnigent-ios-screenshots-#{port}")
    data_dir = File.join(screenshot_root, "data")
    home_dir = File.join(screenshot_root, "home")
    log_dir = File.join(screenshot_root, "logs")
    server_log = File.join(log_dir, "omnigent-server.log")
    server_url = "http://127.0.0.1:#{port}"
    agent_path = File.join(repo_root, "examples/kimi_hello.yaml")
    snapshot_cache = File.expand_path("~/Library/Caches/tools.fastlane/screenshots")

    FileUtils.mkdir_p([data_dir, home_dir, log_dir])
    command = [
      "uv",
      "run",
      "--frozen",
      "--no-sync",
      "--project",
      repo_root,
      "omnigent",
      "server",
      "--host",
      "127.0.0.1",
      "--port",
      port.to_s,
      "--database-uri",
      "sqlite:///#{File.join(data_dir, "chat.db")}",
      "--artifact-location",
      File.join(data_dir, "artifacts"),
      "--no-open",
    ]
    command += ["--agent", agent_path] if File.file?(agent_path)

    server_pid = nil
    begin
      server_pid = omnigent_start_process(
        "Omnigent server",
        {
          "HOME" => home_dir,
          "OMNIGENT_DATA_DIR" => data_dir,
          "OMNIGENT_NO_UPDATE_CHECK" => "1",
        },
        command,
        chdir: repo_root,
        log_path: server_log
      )
      omnigent_wait_for_http("#{server_url}/", timeout_seconds: 60, log_path: server_log)
      FileUtils.rm_rf(snapshot_cache)
      ENV["OMNIGENT_SCREENSHOT_APP_URL"] = server_url
      capture_ios_screenshots(
        only_testing: ["OmnigentUITests/OmnigentUITests/testLocalServerSnapshot"],
        launch_arguments: ["--omnigent-server-url #{server_url}"],
        test_target_name: "Omnigent",
        result_bundle: true,
        number_of_retries: 0,
        stop_after_first_error: true,
        xcodebuild_formatter: ""
      )
    ensure
      omnigent_stop_process(server_pid)
    end
  end

  desc "Build the public app and upload it to TestFlight"
  lane :beta do
    beta_flavor(flavor: :public)
  end

  desc "Build the internal (Databricks) app and upload it to TestFlight"
  lane :beta_internal do
    beta_flavor(flavor: :internal)
  end

  desc "Build the public app and upload it to App Store Connect (no submission)"
  lane :release do
    release_flavor(flavor: :public)
  end

  desc "Build the internal (Databricks) app and upload it to App Store Connect (no submission)"
  lane :release_internal do
    release_flavor(flavor: :internal)
  end

  desc "Prepare the public App Store version using an already-uploaded build"
  lane :prod do
    prod_flavor(flavor: :public)
  end

  desc "Prepare the internal (Databricks) App Store version using an already-uploaded build"
  lane :prod_internal do
    prod_flavor(flavor: :internal)
  end

  # --- helpers ---

  desc "Upload screenshots + metadata for a flavor against an already-uploaded build"
  private_lane :prod_flavor do |options|
    flavor = FLAVORS.fetch(options[:flavor])
    load_asc_api_key
    bundle_id = flavor[:bundle_id]
    build_number = ENV["OMNIGENT_PROD_BUILD_NUMBER"] ||
      latest_testflight_build_number(app_identifier: bundle_id, initial_build_number: 0).to_s
    screenshots_path = File.join(Dir.tmpdir, "omnigent-ios-app-store-screenshots")
    screenshots_locale_path = File.join(screenshots_path, "en-US")
    screenshot_files = Dir[File.join(__dir__, "screenshots/en-US", "*.png")]
    UI.user_error!("Generate screenshots first: bundle exec fastlane screenshots") if screenshot_files.empty?

    FileUtils.rm_rf(screenshots_path)
    FileUtils.mkdir_p(screenshots_locale_path)
    FileUtils.cp(screenshot_files, screenshots_locale_path)

    upload_to_app_store(
      app_identifier: bundle_id,
      app_version: ENV.fetch("OMNIGENT_PROD_APP_VERSION", "0.1.0"),
      build_number: build_number,
      skip_binary_upload: true,
      skip_metadata: false,
      screenshots_path: screenshots_path,
      overwrite_screenshots: true,
      submit_for_review: false,
      automatic_release: false,
      force: true,
      precheck_include_in_app_purchases: false
    )
  end

  desc "Archive a flavor and upload it to TestFlight"
  private_lane :beta_flavor do |options|
    flavor = FLAVORS.fetch(options[:flavor])
    load_asc_api_key
    build(flavor: flavor, build_number: next_build_number(bundle_id: flavor[:bundle_id]))
    upload_to_testflight(
      app_identifier: flavor[:bundle_id],
      skip_waiting_for_build_processing: true
    )
  end

  desc "Archive a flavor and upload it to App Store Connect (no submission)"
  private_lane :release_flavor do |options|
    # NB: this uploads a fresh, uniquely-numbered binary. The more common App
    # Store flow is to *promote* an already-tested TestFlight build instead of
    # uploading a new one — if you adopt that, replace the build/upload below
    # with a submission of the chosen TestFlight build. App Store metadata and
    # screenshots are still a follow-up; this lane uploads but does not submit.
    flavor = FLAVORS.fetch(options[:flavor])
    load_asc_api_key
    build(flavor: flavor, build_number: next_build_number(bundle_id: flavor[:bundle_id]))
    upload_to_app_store(
      app_identifier: flavor[:bundle_id],
      submit_for_review: false,
      skip_metadata: true,
      skip_screenshots: true,
      precheck_include_in_app_purchases: false
    )
  end

  desc "Archive the Release configuration into ./build for the given flavor"
  private_lane :build do |options|
    # Inject the build number as an xcodebuild setting override rather than
    # mutating tracked files. CFBundleVersion is $(CURRENT_PROJECT_VERSION) in
    # the Info.plists, so overriding CURRENT_PROJECT_VERSION here flows into the
    # archived binary — and nothing in the repo changes (no agvtool, no churn).
    # The bundle ID and display name are overridden the same way, so each flavor
    # comes out of the one target without a second Xcode configuration.
    flavor = options[:flavor]
    xcargs = ["-allowProvisioningUpdates"]
    xcargs << "CURRENT_PROJECT_VERSION=#{options[:build_number]}" if options[:build_number]
    if flavor
      xcargs << "PRODUCT_BUNDLE_IDENTIFIER=#{flavor[:bundle_id].shellescape}"
      xcargs << "APP_DISPLAY_NAME=#{flavor[:display_name].shellescape}"
    end
    build_app(
      scheme: "Omnigent",
      configuration: "Release",
      export_method: "app-store",
      xcargs: xcargs.join(" "),
      output_directory: "./build",
      clean: true
    )
  end

  desc "Next build number: one past the highest already on App Store Connect"
  private_lane :next_build_number do |options|
    # TestFlight sees every build (App Store builds pass through it too), so the
    # latest TestFlight build number is a monotonic counter for the whole app.
    # Each flavor is a separate app record, so pass its bundle ID to count the
    # right one. Requires the ASC API key to be loaded first.
    latest_testflight_build_number(
      app_identifier: options[:bundle_id],
      initial_build_number: 0
    ) + 1
  end

  desc "Load the App Store Connect API key from env vars into the session"
  private_lane :load_asc_api_key do
    key_filepath = File.expand_path(omnigent_fetch_env("ASC_KEY_PATH", "APPLE_API_KEY"))
    UI.user_error!("App Store Connect API key file not found: #{key_filepath}") unless File.file?(key_filepath)

    app_store_connect_api_key(
      key_id: omnigent_fetch_env("ASC_KEY_ID", "APPLE_API_KEY_ID"),
      issuer_id: omnigent_fetch_env("ASC_ISSUER_ID", "APPLE_API_ISSUER"),
      key_filepath: key_filepath,
      in_house: false
    )
  end
end
