#!/usr/bin/env ruby

require "json"
require "open3"
require "uri"
require "yaml"

DEFAULT_TOKEN_URL = "https://inside01.api.orange.com/oauth/v3/token"
DEFAULT_ARTIFACT_API_BASE_URL = "https://oma-portal.orange.fr/oma/api/v2/external"
DEFAULT_TESTFLIGHT_API_BASE_URL = "https://inside01.api.orange.com/oma-portal/v2"
PUBLIC_LINK_MODES = %w[false newPublicLink newTestGroup existingGroup lastLink].freeze
DEFAULT_TARGET_RELEASE_STATUS = "waiting_store_approval"
DEFAULT_RELEASE_POLL_SECONDS = 300
FAST_TESTFLIGHT_STATUS_ORDER = %w[
  omaSigning
  omaStoreSubmission
  waiting_store_approval
  published
].freeze
ERROR_RELEASE_STATUSES = %w[
  project_rejected
  invalid_binary
  metadata_rejected
  mu_rejected
  binary_rejected
  oma_rejected
  signing_error
].freeze
REQUIRED_ENV_VARS = %w[
  OMA_OAUTH_CLIENT_ID
  OMA_OAUTH_CLIENT_SECRET
  OMA_API_KEY
].freeze

def abort_with(message)
  warn(message)
  exit(1)
end

def require_env(name)
  value = ENV[name]
  abort_with("Missing required environment variable #{name}") if value.nil? || value.empty?
  value
end

def validate_environment!
  missing = REQUIRED_ENV_VARS.select { |name| ENV[name].nil? || ENV[name].empty? }
  return if missing.empty?

  abort_with(
    "Missing required GitLab CI variable(s): #{missing.join(", ")}\n" \
    "Define them in GitLab project Settings > CI/CD > Variables before running the testflight pipeline.\n" \
    "OMA_OAUTH_CLIENT_ID and OMA_OAUTH_CLIENT_SECRET are used to fetch the OAuth token.\n" \
    "OMA_API_KEY is sent as the v2 API apiKey header."
  )
end

def require_config_value(value, name)
  abort_with("Missing #{name} in config") if value.nil? || value.to_s.empty?
  value
end

def run_command(*command)
  puts("+ #{command.join(" ")}")
  stdout, stderr, status = Open3.capture3(*command)
  print(stdout) unless stdout.empty?
  warn(stderr) unless stderr.empty?
  abort_with("Command failed with exit code #{status.exitstatus}: #{command.join(" ")}") unless status.success?
end

def deep_fetch(hash, *keys)
  keys.reduce(hash) do |current, key|
    current.is_a?(Hash) ? current[key] : nil
  end
end

def expand_value(value)
  return value.map { |item| expand_value(item) } if value.is_a?(Array)
  return value.transform_values { |item| expand_value(item) } if value.is_a?(Hash)
  return value unless value.is_a?(String)

  value.gsub(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/) { ENV.fetch(Regexp.last_match(1), "") }
end

def load_config(path)
  abort_with("Config file not found: #{path}") unless File.file?(path)
  expand_value(YAML.safe_load(File.read(path), aliases: true) || {})
end

def create_archive_zip(xcarchive_path)
  abort_with("xcarchive not found: #{xcarchive_path}") unless File.directory?(xcarchive_path)

  archive_dir = File.dirname(xcarchive_path)
  archive_name = File.basename(xcarchive_path)
  zip_path = File.expand_path("archive.zip", archive_dir)
  File.delete(zip_path) if File.exist?(zip_path)

  Dir.chdir(archive_dir) do
    run_command("zip", "-r", "-X", File.basename(zip_path), archive_name)
  end

  zip_path
end

def oauth_token
  client_id = require_env("OMA_OAUTH_CLIENT_ID")
  client_secret = require_env("OMA_OAUTH_CLIENT_SECRET")
  token_url = ENV.fetch("OMA_OAUTH_TOKEN_URL", DEFAULT_TOKEN_URL)
  form_data = { "grant_type" => "client_credentials" }
  form_data["scope"] = ENV["OMA_OAUTH_SCOPE"] if ENV["OMA_OAUTH_SCOPE"] && !ENV["OMA_OAUTH_SCOPE"].empty?

  body = curl_json(
    token_url,
    [
      "-u", "#{client_id}:#{client_secret}",
      "-H", "Accept: application/json",
      "-H", "Content-Type: application/x-www-form-urlencoded",
    ] + form_data.flat_map { |key, value| ["--data-urlencode", "#{key}=#{value}"] },
    "OAuth token request",
  )
  body["access_token"] || abort_with("OAuth token response does not contain access_token")
end

def curl_json(url, args, label, method = "POST")
  puts("#{label}: #{url}")
  stdout, stderr, status = Open3.capture3(
    "curl",
    "-sS",
    "-L",
    "--connect-timeout", ENV.fetch("OMA_HTTP_OPEN_TIMEOUT", "60"),
    "--max-time", ENV.fetch("OMA_HTTP_READ_TIMEOUT", "300"),
    "-w", "\n__OMA_HTTP_CODE__%{http_code}",
    "-X", method,
    url,
    *args,
  )
  stdout = stdout.b
  stderr = stderr.b
  warn(stderr) unless stderr.empty?
  abort_with("#{label} failed to execute curl: #{status.exitstatus}") unless status.success?

  response_text, status_text = stdout.split("\n__OMA_HTTP_CODE__", 2)
  abort_with("#{label} response did not contain HTTP status marker") unless status_text
  status_code = status_text.to_i
  body = response_text.force_encoding("UTF-8")

  unless status_code.between?(200, 299)
    abort_with("#{label} failed: HTTP #{status_code} #{body}")
  end

  JSON.parse(body)
rescue JSON::ParserError => error
  abort_with("#{label} returned invalid JSON: #{error}")
end

def get_json(url, headers, label)
  curl_json(
    url,
    headers.flat_map { |name, value| ["-H", "#{name}: #{value}"] },
    label,
    "GET",
  )
end

def post_multipart(url, headers, fields, files = {})
  form_args = []
  fields.each do |name, value|
    form_args.concat(["-F", "#{name}=#{value}"]) unless value.nil?
  end

  files.each do |name, path|
    next if path.nil?
    abort_with("File not found for multipart field #{name}: #{path}") unless File.file?(path)
    form_args.concat(["-F", "#{name}=@#{path}"])
  end

  curl_json(
    url,
    headers.flat_map { |name, value| ["-H", "#{name}: #{value}"] } + form_args,
    "POST multipart",
  )
end

def log_multipart_payload(label, fields, files = {})
  puts("#{label} multipart fields:")
  fields.each do |name, value|
    puts("  #{name}=#{value}") unless value.nil?
  end

  files.each do |name, path|
    next if path.nil?

    size = File.file?(path) ? File.size(path) : "missing"
    puts("  #{name}=#{path} size=#{size}")
  end
end

def create_artifact(app_id, store, zip_path, token, api_key)
  artifact_api_base_url = ENV.fetch("OMA_ARTIFACT_API_BASE_URL", DEFAULT_ARTIFACT_API_BASE_URL).sub(%r{/\z}, "")
  artifact_url = "#{artifact_api_base_url}/applications/#{URI.encode_www_form_component(app_id)}/artifacts?store=#{URI.encode_www_form_component(store)}"
  artifact_response = post_multipart(
    artifact_url,
    {
      "Authorization" => "Bearer #{token}",
      "apiKey" => api_key,
      "Accept" => "application/json",
    },
    {},
    { "file" => zip_path },
  )

  artifact_id = artifact_response["artifactId"] || artifact_response["_id"] || artifact_response["id"]
  abort_with("Artifact creation response does not contain artifactId, _id, or id: #{artifact_response}") unless artifact_id

  artifact_id
end

def create_testflight(app_id, artifact_id, token, api_key, fields, csv_path)
  testflight_api_base_url = ENV.fetch("OMA_TESTFLIGHT_API_BASE_URL", DEFAULT_TESTFLIGHT_API_BASE_URL).sub(%r{/\z}, "")
  testflight_url = "#{testflight_api_base_url}/applications/#{URI.encode_www_form_component(app_id)}/artifacts/#{URI.encode_www_form_component(artifact_id)}/testflight"
  headers = {
    "Authorization" => "Bearer #{token}",
    "apiKey" => api_key,
    "Accept" => "application/json",
  }
  puts("TestFlight creation URL: #{testflight_url}")
  files = csv_path ? { "csv" => csv_path } : {}
  log_multipart_payload("TestFlight creation", fields, files)

  post_multipart(
    testflight_url,
    headers,
    fields,
    files,
  )
end

def release_status(app_id, release_id, token, api_key)
  testflight_api_base_url = ENV.fetch("OMA_TESTFLIGHT_API_BASE_URL", DEFAULT_TESTFLIGHT_API_BASE_URL).sub(%r{/\z}, "")
  release_url = "#{testflight_api_base_url}/applications/#{URI.encode_www_form_component(app_id)}/releases/#{URI.encode_www_form_component(release_id)}"

  get_json(
    release_url,
    {
      "Authorization" => "Bearer #{token}",
      "apiKey" => api_key,
      "Accept" => "application/json",
    },
    "Release status request",
  )
end

def status_reached_or_passed?(status, target_status)
  status_index = FAST_TESTFLIGHT_STATUS_ORDER.index(status)
  target_index = FAST_TESTFLIGHT_STATUS_ORDER.index(target_status)

  return status == target_status if status_index.nil? || target_index.nil?

  status_index >= target_index
end

def wait_for_release_status(app_id, release_id, token, api_key, target_status, poll_seconds)
  puts("Waiting for release #{release_id} to reach status #{target_status}...")

  loop do
    release = release_status(app_id, release_id, token, api_key)
    status = release["status"]
    step = release["step"] || release["currentStep"]
    automatic_signing_status = release["automaticSigningStatus"]
    signing_error = release["signingError"]

    abort_with("Release status response does not contain status: #{release}") if status.nil? || status.empty?

    puts("Release #{release_id}: status=#{status} step=#{step || '<missing>'} automaticSigningStatus=#{automatic_signing_status || '<missing>'}")

    if status_reached_or_passed?(status, target_status)
      puts("Release #{release_id} reached or passed target status #{target_status}: current status=#{status}.")
      return release
    end

    if ERROR_RELEASE_STATUSES.include?(status) || automatic_signing_status == "failed" || (signing_error && !signing_error.empty?)
      abort_with("Release #{release_id} failed before reaching #{target_status}: status=#{status} automaticSigningStatus=#{automatic_signing_status} signingError=#{signing_error}")
    end

    sleep(poll_seconds)
  end
end

def normalized_testflight_fields(config)
  testflight = config.fetch("testflight", {})
  distribution = testflight.fetch("distribution", {})
  public_link = distribution["publicLink"]
  abort_with("Invalid testflight.distribution.publicLink: #{public_link}") if public_link && !PUBLIC_LINK_MODES.include?(public_link)
  require_config_value(distribution["group"], "testflight.distribution.group") if %w[newTestGroup existingGroup].include?(public_link)

  fields = {
    "requestType" => "type_testFlight",
    "store" => "app_store_ios",
    "securityAnalysis" => require_config_value(testflight["securityAnalysis"], "testflight.securityAnalysis").to_s,
    "whatsNew" => require_config_value(testflight["whatsNew"], "testflight.whatsNew"),
    "feedbackEmail" => testflight["feedbackEmail"],
    "description" => testflight["description"],
    "marketingURL" => testflight["marketingURL"],
    "privacyPolicyURL" => testflight["privacyPolicyURL"],
    "demoAccountLogin" => testflight["demoAccountLogin"],
    "demoAccountPassword" => testflight["demoAccountPassword"],
    "publicLink" => public_link,
    "group" => distribution["group"],
    "lastPublicLink" => distribution["lastPublicLink"],
  }

  extensions = testflight.fetch("extensions", {})
  extensions.each do |path, bundle_id|
    fields[path] = bundle_id
  end

  fields
end

validate_environment!

config_path = ARGV[0] || "oma-testflight.yml"
config = load_config(config_path)

app_id = require_config_value(config["appId"], "appId")
artifact = config.fetch("artifact", {})
xcarchive_path = require_config_value(artifact["path"], "artifact.path")
store = artifact.fetch("store", "app_store_ios")

zip_path = create_archive_zip(xcarchive_path)
api_key = require_env("OMA_API_KEY")
token = oauth_token

artifact_id = create_artifact(app_id, store, zip_path, token, api_key)
puts("Created OMA artifact #{artifact_id}")

csv_path = deep_fetch(config, "testflight", "distribution", "csv")
testflight_config = config.fetch("testflight", {})
testflight_response = create_testflight(
  app_id,
  artifact_id,
  token,
  api_key,
  normalized_testflight_fields(config),
  csv_path,
)

release_id = testflight_response["releaseId"] || testflight_response["_id"] || testflight_response["id"]
abort_with("TestFlight creation response does not contain releaseId, _id, or id: #{testflight_response}") unless release_id

puts("Created OMA TestFlight request #{release_id}")

target_status = testflight_config["waitForStatus"] || DEFAULT_TARGET_RELEASE_STATUS
poll_seconds = Integer(testflight_config["pollIntervalSeconds"] || DEFAULT_RELEASE_POLL_SECONDS)
wait_for_release_status(app_id, release_id, token, api_key, target_status, poll_seconds)
