require "json"
require "open3"
require "shellwords"
require "tempfile"
require "yaml"

require_relative "release_automation"

default_platform(:mac)

APP_NAME      = "Zentty"
SCHEME        = "Zentty"
PROJECT       = "Zentty.xcodeproj"
PROJECT_SPEC  = "project.yml"
APP_TARGET    = "Zentty"
BUILD_DIR     = "build/release"
SOURCE_PACKAGES_DIR = "build/SourcePackages"
PACKAGE_RESOLVED = "#{PROJECT}/project.xcworkspace/xcshareddata/swiftpm/Package.resolved"
APP_PATH      = "#{BUILD_DIR}/#{APP_NAME}.app"
PROJECT_ROOT  = File.expand_path("..", __dir__)
R2_CACHE_MAX  = "public, max-age=31536000, immutable"
R2_CACHE_NO   = "no-cache"

def project_root_path(*parts)
  File.join(PROJECT_ROOT, *parts)
end

def run_command(*args, chdir: PROJECT_ROOT)
  stdout, stderr, status = Open3.capture3(*args, chdir: chdir)
  [stdout, stderr, status]
end

def xcrun_path
  "/usr/bin/xcrun"
end

def capture_command!(*args, chdir: PROJECT_ROOT, error_message: nil)
  stdout, stderr, status = run_command(*args, chdir: chdir)
  return stdout if status.success?

  message = error_message || "Command failed: #{args.join(" ")}"
  message += "\n#{stderr.strip}" unless stderr.strip.empty?
  # xcodebuild reports compile/config errors on stdout, not stderr.
  stdout_errors = stdout.lines.grep(/(^|: )error: /).map(&:strip).uniq
  message += "\n#{stdout_errors.join("\n")}" unless stdout_errors.empty?
  UI.user_error!(message)
end

def command_success?(*args, chdir: PROJECT_ROOT)
  _, _, status = run_command(*args, chdir: chdir)
  status.success?
end

def one_password_reference?(value)
  value.to_s.strip.start_with?("op://")
end

def ensure_one_password_ready!
  capture_command!("op", "account", "list", error_message: "1Password CLI is not authenticated")
end

def read_one_password_reference!(reference, label:)
  ensure_one_password_ready!
  capture_command!("op", "read", "--no-newline", reference, error_message: "Failed to read #{label} from 1Password")
end

def release_env_item_name
  ENV["OP_RELEASE_ENV_ITEM"].to_s.strip
end

def release_env_item_vault
  ENV["OP_RELEASE_ENV_VAULT"].to_s.strip
end

def normalized_release_env_key(label)
  label.to_s.strip.upcase.gsub(/[^A-Z0-9]+/, "_").gsub(/\A_+|_+\z/, "")
end

def hydrate_release_env_from_one_password_item!
  item_name = release_env_item_name
  return if item_name.empty?

  ensure_one_password_ready!

  args = ["op", "item", "get", item_name, "--format", "json", "--reveal"]
  args += ["--vault", release_env_item_vault] unless release_env_item_vault.empty?
  payload = JSON.parse(capture_command!(*args, error_message: "Failed to read release env item from 1Password"))

  Array(payload["fields"]).each do |field|
    next if field["purpose"] == "PASSWORD" || field["purpose"] == "NOTES"

    env_key = normalized_release_env_key(field["label"] || field["id"])
    next if env_key.empty?

    value = field["value"].to_s
    next if value.empty?

    ENV[env_key] = value if ENV[env_key].to_s.empty?
  end
end

def hydrate_release_env_from_one_password!
  return if defined?(@release_env_from_one_password_loaded) && @release_env_from_one_password_loaded

  @release_env_from_one_password_loaded = true
  hydrate_release_env_from_one_password_item!

  reference = ENV["OP_RELEASE_ENV_REF"].to_s.strip
  return if reference.empty?

  read_one_password_reference!(reference, label: "OP_RELEASE_ENV_REF").each_line do |line|
    entry = line.strip
    next if entry.empty? || entry.start_with?("#")

    key, value = entry.split("=", 2)
    next if key.to_s.strip.empty? || value.nil?

    normalized_key = key.strip
    ENV[normalized_key] = value if ENV[normalized_key].to_s.empty?
  end
end

def resolved_env_value(name)
  hydrate_release_env_from_one_password!

  value = ENV[name].to_s.strip
  return "" if value.empty?

  return read_one_password_reference!(value, label: name).rstrip if one_password_reference?(value)

  value
end

def required_env_value!(name)
  value = resolved_env_value(name)
  UI.user_error!("#{name} must be set") if value.empty?
  value
end

def github_repo_name_with_owner
  capture_command!("gh", "repo", "view", "--json", "nameWithOwner", "--jq", ".nameWithOwner").strip
end

def github_release_records
  output = capture_command!("gh", "release", "list", "--limit", "100", "--json", "tagName,isPrerelease,isDraft,publishedAt")
  JSON.parse(output)
rescue JSON::ParserError => e
  UI.user_error!("Could not parse gh release list output: #{e.message}")
end

def latest_published_version(channel: nil)
  releases = github_release_records
  record =
    case ReleaseAutomation.normalize_channel(channel)
    when "stable"
      releases.find { |release| !release["isPrerelease"] && !release["isDraft"] }
    when "beta"
      releases.find { |release| release["isPrerelease"] && !release["isDraft"] } ||
        releases.find { |release| !release["isDraft"] }
    else
      releases.find { |release| !release["isDraft"] }
    end

  ReleaseAutomation.normalized_version(record && record["tagName"])
rescue StandardError
  nil
end

def latest_release_tag(excluding: nil)
  releases = github_release_records
  releases.each do |release|
    tag = release["tagName"].to_s.strip
    next if tag.empty?
    next if ReleaseAutomation.normalized_version(tag) == ReleaseAutomation.normalized_version(excluding)
    return tag
  end
  nil
end

def current_head_commit
  capture_command!("git", "-C", PROJECT_ROOT, "rev-parse", "HEAD").strip
end

def local_tag_commit(tag_name)
  capture_command!("git", "-C", PROJECT_ROOT, "rev-parse", "#{tag_name}^{commit}").strip
rescue StandardError
  nil
end

def git_tag_exists?(tag_name)
  command_success?("git", "-C", PROJECT_ROOT, "rev-parse", "--verify", "--quiet", "refs/tags/#{tag_name}")
end

def remote_tag_exists?(tag_name)
  stdout = capture_command!("git", "-C", PROJECT_ROOT, "ls-remote", "--tags", "origin", "refs/tags/#{tag_name}")
  !stdout.strip.empty?
rescue StandardError
  false
end

def github_release_exists?(tag_name)
  command_success?("gh", "release", "view", tag_name, "--json", "tagName", "--jq", ".tagName")
end

def ensure_release_not_conflicting!(tag_name)
  if git_tag_exists?(tag_name)
    tagged_commit = local_tag_commit(tag_name)
    if tagged_commit != current_head_commit
      UI.user_error!("Tag #{tag_name} already exists and points to #{tagged_commit}, not the release commit #{current_head_commit}")
    end
  end

  if !git_tag_exists?(tag_name) && remote_tag_exists?(tag_name)
    UI.user_error!("Tag #{tag_name} already exists on origin")
  end

  if github_release_exists?(tag_name)
    UI.message("GitHub release #{tag_name} already exists; its notes body will be updated.")
  end
end

def ensure_release_tools_ready!
  hydrate_release_env_from_one_password!
  sparkle_pin!
  capture_command!("gh", "auth", "status", "--hostname", "github.com", error_message: "gh is not authenticated")
  capture_command!("wrangler", "whoami", error_message: "wrangler is not authenticated")
  UI.user_error!("R2_PUBLIC_BASE_URL must be set") if r2_public_base_url.empty?
  UI.user_error!("SPARKLE_PUBLIC_ED_KEY must be set") if sparkle_public_ed_key.empty?
  if glitchtip_dsn.empty?
    UI.important("GLITCHTIP_DSN is not set; this release build will ship without error reporting")
  else
    ensure_glitchtip_upload_ready!
  end
end

def determine_release_channel(options)
  requested = ReleaseAutomation.normalize_channel(options[:channel])
  return requested if requested

  return "stable" unless UI.interactive?

  UI.select("Select the release channel", ReleaseAutomation::CHANNELS)
end

def determine_release_version(channel:, requested_version:)
  latest_stable_version = latest_published_version(channel: "stable")
  default_version = ReleaseAutomation.suggested_version(
    channel: channel,
    latest_version: latest_stable_version,
    fallback_version: read_project_marketing_version
  )

  if requested_version.to_s.strip.empty?
    return default_version unless UI.interactive?

    if latest_stable_version
      UI.message("Latest published stable version: #{latest_stable_version}")
    else
      UI.message("Latest published stable version: none")
      UI.message("Using current project version #{read_project_marketing_version} as the first release default")
    end
    UI.message("Suggested release version: #{default_version}")

    return default_version if UI.confirm("Use #{default_version} as the release version?")

    loop do
      response = UI.input("Enter release version:").to_s.strip
      return response unless response.empty?
      UI.important("Version cannot be empty.")
    end
  end

  requested_version.to_s.strip
end

def prompt_release_confirmation(channel:, version:)
  return if !UI.interactive?

  UI.message("Preparing #{channel} release #{version}")
  UI.confirm("Continue with this release?") || UI.user_error!("Release cancelled")
end

def release_tag_name(version)
  ReleaseAutomation.release_tag(version)
end

def release_title(version)
  "Zentty #{version}"
end

def load_api_key
  app_store_connect_api_key(
    key_id: required_env_value!("ASC_KEY_ID"),
    issuer_id: required_env_value!("ASC_ISSUER_ID"),
    key_filepath: required_env_value!("ASC_KEY_FILEPATH")
  )
end

def read_project_marketing_version
  zentty_target_settings.fetch("MARKETING_VERSION").to_s
end

def read_project_build_number
  zentty_target_settings.fetch("CURRENT_PROJECT_VERSION").to_s
end

def write_project_marketing_version(version:)
  project_spec_path = File.join(PROJECT_ROOT, PROJECT_SPEC)
  project_spec = File.read(project_spec_path)
  UI.user_error!("MARKETING_VERSION not found in #{PROJECT_SPEC}") unless ReleaseAutomation.yaml_scalar_present?(
    content: project_spec,
    key: "MARKETING_VERSION"
  )
  updated_project_spec = ReleaseAutomation.replace_yaml_scalar(
    content: project_spec,
    key: "MARKETING_VERSION",
    value: version
  )

  File.write(project_spec_path, updated_project_spec)
end

def write_project_build_number(build_number:)
  project_spec_path = File.join(PROJECT_ROOT, PROJECT_SPEC)
  project_spec = File.read(project_spec_path)
  UI.user_error!("CURRENT_PROJECT_VERSION not found in #{PROJECT_SPEC}") unless ReleaseAutomation.yaml_scalar_present?(
    content: project_spec,
    key: "CURRENT_PROJECT_VERSION"
  )
  updated_project_spec = ReleaseAutomation.replace_yaml_scalar(
    content: project_spec,
    key: "CURRENT_PROJECT_VERSION",
    value: build_number
  )

  File.write(project_spec_path, updated_project_spec)
end

def load_project_spec
  YAML.load_file(File.join(PROJECT_ROOT, PROJECT_SPEC))
end

def zentty_target_settings
  load_project_spec.fetch("targets").fetch(APP_TARGET).fetch("settings").fetch("base")
end

def validate_release_inputs!(channel:, version:)
  ReleaseAutomation.validate_version!(channel: channel, version: version)
rescue ArgumentError => e
  UI.user_error!(e.message)
end

def release_build_number
  head_count = capture_command!("git", "-C", PROJECT_ROOT, "rev-list", "--count", "HEAD").strip.to_i
  highest_published = highest_existing_build_number
  ([head_count + 1, highest_published + 1].max).to_s
end

def highest_existing_build_number
  Dir[File.join(sparkle_updates_dir, "Zentty-*-*.zip")]
    .map { |path| File.basename(path)[/-(\d+)\.zip\z/, 1].to_i }
    .reject(&:zero?)
    .max || 0
end

def sparkle_public_ed_key
  resolved_env_value("SPARKLE_PUBLIC_ED_KEY")
end

def glitchtip_dsn
  resolved_env_value("GLITCHTIP_DSN")
end

def glitchtip_url
  resolved_env_value("SENTRY_URL")
end

def glitchtip_auth_token
  resolved_env_value("SENTRY_AUTH_TOKEN")
end

def glitchtip_org
  resolved_env_value("SENTRY_ORG")
end

def glitchtip_project
  resolved_env_value("SENTRY_PROJECT")
end

def sparkle_private_ed_key_file
  explicit_path = resolved_env_value("SPARKLE_PRIVATE_ED_KEY_FILE")
  return explicit_path unless explicit_path.empty?

  inline_key = resolved_env_value("SPARKLE_PRIVATE_ED_KEY")
  return "" if inline_key.empty?

  @sparkle_private_ed_key_tempfile ||= begin
    file = Tempfile.new(["zentty-sparkle-private-ed25519", ".txt"])
    file.write("#{inline_key}\n")
    file.flush
    File.chmod(0o600, file.path)
    file
  end

  @sparkle_private_ed_key_tempfile.path
end

def sparkle_keychain_account
  account = resolved_env_value("SPARKLE_KEYCHAIN_ACCOUNT")
  account.empty? ? "zentty" : account
end

def sparkle_updates_dir
  project_root_path("build", "updates")
end

def sparkle_appcast_path
  File.join(sparkle_updates_dir, ReleaseAutomation.appcast_key)
end

def sparkle_feed_url
  "#{r2_public_base_url.sub(%r{/+\z}, "")}/#{ReleaseAutomation.appcast_key}"
end

def xcargs_for_release(version:)
  [
    "MARKETING_VERSION=#{Shellwords.escape(version)}",
    "SPARKLE_FEED_URL=#{Shellwords.escape(sparkle_feed_url)}",
    "SPARKLE_PUBLIC_ED_KEY=#{Shellwords.escape(sparkle_public_ed_key)}",
    "GLITCHTIP_DSN=#{Shellwords.escape(glitchtip_dsn)}"
  ].join(" ")
end

def commit_release_metadata!(version:)
  build_number = release_build_number
  write_project_marketing_version(version: version)
  write_project_build_number(build_number: build_number)
  generate_project!
  sh(
    "git", "-C", PROJECT_ROOT,
    "commit", "--only",
    "-m", "release: #{version} (build #{build_number})",
    "--", PROJECT_SPEC, PROJECT
  )
  build_number
end

def generate_project!
  sh("xcodegen", "generate", "--spec", "../#{PROJECT_SPEC}")
end

def sync_signing!
  match(
    type: "developer_id",
    platform: "macos",
    readonly: true,
    skip_provisioning_profiles: true
  )
end

def archive_release!(version:)
  build_mac_app(
    project: PROJECT,
    scheme: SCHEME,
    configuration: "Release",
    clean: true,
    output_directory: BUILD_DIR,
    output_name: APP_NAME,
    export_method: "developer-id",
    export_options: "fastlane/ExportOptions.plist",
    cloned_source_packages_path: project_root_path(SOURCE_PACKAGES_DIR),
    disable_package_automatic_updates: true,
    xcargs: xcargs_for_release(version: version)
  )
end

def resolved_glitchtip_upload_env
  {
    "SENTRY_URL" => glitchtip_url,
    "SENTRY_AUTH_TOKEN" => glitchtip_auth_token,
    "SENTRY_ORG" => glitchtip_org,
    "SENTRY_PROJECT" => glitchtip_project
  }
end

def ensure_glitchtip_upload_ready!
  missing = ReleaseAutomation.missing_glitchtip_upload_env_names(resolved_glitchtip_upload_env)
  UI.user_error!("Missing GlitchTip dSYM upload env: #{missing.join(", ")}") unless missing.empty?
  capture_command!("glitchtip-cli", "--version", error_message: "glitchtip-cli must be installed to upload dSYMs")
end

def upload_glitchtip_dsyms!(upload_root: nil)
  return if glitchtip_dsn.empty?

  ensure_glitchtip_upload_ready!

  upload_root = upload_root.to_s.strip
  upload_root = File.join(PROJECT_ROOT, BUILD_DIR) if upload_root.empty?
  upload_root = File.expand_path(upload_root, PROJECT_ROOT)
  UI.user_error!("dSYM upload path does not exist: #{upload_root}") unless File.directory?(upload_root)

  # gym exports dSYMs as .dSYM.zip — unzip them so glitchtip-cli can find the bundles
  Dir[File.join(upload_root, "**", "*.dSYM.zip")].each do |zip|
    Actions.sh("unzip", "-o", "-q", zip, "-d", File.dirname(zip))
  end

  dsym_paths = Dir[File.join(upload_root, "**", "*.dSYM")]
  UI.user_error!("No dSYM bundles found under #{upload_root}") if dsym_paths.empty?

  command = ReleaseAutomation.glitchtip_debug_files_upload_command(
    upload_root: upload_root,
    url: glitchtip_url,
    auth_token: glitchtip_auth_token,
    org: glitchtip_org,
    project: glitchtip_project
  )
  capture_command!(*command, error_message: "Failed to upload dSYMs to GlitchTip")
end

def notarize_app_bundle!
  notarize(
    package: APP_PATH,
    api_key: load_api_key,
    print_log: true
  )
end

def package_release_dmg!
  built_info_plist = File.join(PROJECT_ROOT, APP_PATH, "Contents", "Info.plist")
  version = get_info_plist_value(path: built_info_plist, key: "CFBundleShortVersionString")
  build   = get_info_plist_value(path: built_info_plist, key: "CFBundleVersion")

  dmg_name  = "#{APP_NAME}-#{version}-#{build}"
  abs_build = File.join(PROJECT_ROOT, BUILD_DIR)
  abs_app   = File.join(PROJECT_ROOT, APP_PATH)
  abs_dmg   = File.join(abs_build, "#{dmg_name}.dmg")
  bg_image  = File.join(PROJECT_ROOT, "assets", "dmg", "background.tiff")

  FileUtils.rm_f(abs_dmg)

  staging = File.join(abs_build, "dmg-staging")
  FileUtils.rm_rf(staging)
  FileUtils.mkdir_p(staging)
  FileUtils.cp_r(abs_app, staging)

  args = [
    "create-dmg",
    "--volname", APP_NAME,
    "--window-pos", "200", "120",
    "--window-size", "660", "400",
    "--icon-size", "128",
    "--text-size", "14",
    "--icon", "#{APP_NAME}.app", "200", "170",
    "--hide-extension", "#{APP_NAME}.app",
    "--app-drop-link", "460", "170",
    "--no-internet-enable",
    "--format", "UDZO"
  ]
  args += ["--background", bg_image] if File.exist?(bg_image)
  args += [abs_dmg, staging]

  sh(*args)

  FileUtils.rm_rf(staging)
  UI.success("DMG created: #{abs_dmg}")

  ENV["ZENTTY_DMG_PATH"] = abs_dmg
end

def package_release_zip!
  built_info_plist = File.join(PROJECT_ROOT, APP_PATH, "Contents", "Info.plist")
  version = get_info_plist_value(path: built_info_plist, key: "CFBundleShortVersionString")
  build   = get_info_plist_value(path: built_info_plist, key: "CFBundleVersion")
  basename = ReleaseAutomation.archive_basename(app_name: APP_NAME, version: version, build: build)
  abs_build = File.join(PROJECT_ROOT, BUILD_DIR)
  abs_app = File.join(PROJECT_ROOT, APP_PATH)
  abs_zip = File.join(abs_build, "#{basename}.zip")

  FileUtils.rm_f(abs_zip)
  sh("ditto", "-c", "-k", "--sequesterRsrc", "--keepParent", abs_app, abs_zip)
  UI.success("ZIP created: #{abs_zip}")

  ENV["ZENTTY_ARCHIVE_BASENAME"] = basename
  ENV["ZENTTY_ZIP_PATH"] = abs_zip
end

def notarize_release_dmg!
  abs_build = File.join(PROJECT_ROOT, BUILD_DIR)
  dmg_path = ENV["ZENTTY_DMG_PATH"] || Dir["#{abs_build}/*.dmg"].first
  UI.user_error!("No DMG found in #{abs_build}") unless dmg_path && File.exist?(dmg_path)

  notarize(
    package: dmg_path,
    api_key: load_api_key,
    print_log: true
  )
  UI.success("DMG notarized and stapled: #{dmg_path}")
end

def local_tag_release!(tag_name, version)
  return if git_tag_exists?(tag_name) && local_tag_commit(tag_name) == current_head_commit

  sh("git", "-C", PROJECT_ROOT, "tag", "-a", tag_name, "-m", release_title(version))
end

def push_release_tag!(tag_name)
  sh("git", "-C", PROJECT_ROOT, "push", "origin", tag_name)
end

def release_notes_path(archive_basename:)
  File.join(sparkle_updates_dir, "#{archive_basename}.md")
end

def previous_release_tag_for(tag_name)
  latest_release_tag(excluding: tag_name)
end

def generated_release_notes(tag_name:, previous_tag_name:)
  repo = github_repo_name_with_owner
  args = ["gh", "api", "repos/#{repo}/releases/generate-notes", "-f", "tag_name=#{tag_name}"]
  args += ["-f", "previous_tag_name=#{previous_tag_name}"] unless previous_tag_name.to_s.strip.empty?
  stdout = capture_command!(*args)
  JSON.parse(stdout).fetch("body")
rescue JSON::ParserError => e
  UI.user_error!("Could not parse generated release notes: #{e.message}")
end

def draft_release_notes_with_codex(tag_name:, previous_tag_name:, fallback_text:)
  return fallback_text if ENV["ZENTTY_RELEASE_NOTES_AI"] == "0"
  return fallback_text if previous_tag_name.to_s.strip.empty?
  return fallback_text unless command_success?("which", "codex")

  git_log = capture_command!(
    "git", "-C", PROJECT_ROOT, "log",
    "#{previous_tag_name}..#{tag_name}",
    "--pretty=format:===%h %s===%nAuthor: %an <%ae>%n%b",
    "--stat"
  )
  return fallback_text if git_log.strip.empty?

  repo = github_repo_name_with_owner
  compare_url = "https://github.com/#{repo}/compare/#{previous_tag_name}...#{tag_name}"

  prompt = <<~PROMPT
    Draft user-facing release notes for Zentty (a macOS terminal multiplexer with
    Claude Code integration), for the release going from #{previous_tag_name} to #{tag_name}.

    Output ONLY the markdown body. No preamble. No code fences. No "Here are the notes".

    Style rules:
    - Group changes under `## New`, `## Improved`, `## Fixed` (omit any empty section).
    - One bullet per change. Terse, direct, user-facing — describe what users notice,
      not how it was implemented.
    - Skip release/version-bump commits and pure-internal refactors that have no
      user impact. Merge duplicates.
    - If a user-facing change came from an outside contributor, append a short
      thanks to that bullet, e.g. `- Added pane border controls. Thanks @mvanhorn!`
      Do not create standalone thanks bullets.
    - End the document with exactly: **Full Changelog**: #{compare_url}

    Important — interpreting commits:
    - Each commit shows its message, author, and file-change stat (paths and line counts).
    - When a commit message is vague (e.g. "snapshot", "wip", "chore"), DO NOT skip it.
      Infer the user-facing impact from the file paths and line counts. A vague commit
      touching CommandPalette/, Settings/, NotificationStore, AgentReducer etc. clearly
      contains real features — describe each affected area as its own bullet. Group
      related file changes into one user-facing bullet.
    - Tests, .pbxproj, project.yml, fastlane/, and other build/infra paths do NOT count
      as user-facing on their own — only mention them if they're the entire commit's
      purpose (e.g. release tooling itself).
    - Contributor shoutouts: use the GitHub handle from merge subjects like
      `Merge pull request #123 from handle/branch`, from `123456+handle@users.noreply.github.com`
      author emails, or from commit/PR text. Ignore Peter Dedene, dedene, zenjoy,
      bots, and AI co-authors. If Peter-only follow-up commits refine the same
      contributed feature in this release, keep the thanks on the final merged bullet.
      If no GitHub handle is available, do not guess one.
    - When in doubt, lean toward including more detail rather than less. It's better to
      over-list than to drop a feature.

    Commits between #{previous_tag_name} and #{tag_name} (commit message followed by file stats):

    #{git_log}
  PROMPT

  UI.message("Drafting release notes with Codex (#{previous_tag_name}..#{tag_name})...")
  Tempfile.create(["zentty-codex-release-notes-", ".md"]) do |file|
    output_path = file.path
    file.close

    stdout, stderr, status = Open3.capture3(
      *ReleaseAutomation.codex_release_notes_command(output_path: output_path, prompt: prompt),
      chdir: PROJECT_ROOT
    )
    draft = File.read(output_path).strip

    if status.success? && !draft.empty?
      UI.success("Codex drafted release notes (#{draft.lines.count} lines)")
      "#{draft}\n"
    else
      UI.important("Codex draft failed; falling back to GitHub-generated notes")
      diagnostic = stderr.strip.empty? ? stdout.strip : stderr.strip
      UI.important(diagnostic[0..300]) unless diagnostic.empty?
      fallback_text
    end
  end
rescue StandardError => e
  UI.important("Codex draft errored (#{e.message}); falling back to GitHub-generated notes")
  fallback_text
end

def edit_text_in_editor!(initial_text, path_hint:)
  FileUtils.mkdir_p(File.dirname(path_hint))
  Tempfile.create(["zentty-release-notes-", ".md"], File.dirname(path_hint)) do |file|
    file.write(initial_text)
    file.flush

    editor = ReleaseAutomation.release_notes_editor_command(ENV["EDITOR"])
    UI.message("Opening #{path_hint} in #{editor.first}")
    UI.message("Waiting for #{editor.first} to close...")
    sh(*editor, file.path)
    UI.success("#{editor.first} closed — capturing release notes")

    edited_text = File.read(file.path)
    UI.user_error!("Release notes file was left empty") if edited_text.strip.empty?

    File.write(path_hint, edited_text)
    UI.message("Release notes saved (#{edited_text.lines.count} lines) -> #{path_hint}")
    return edited_text
  end
end

def r2_bucket_name
  bucket = resolved_env_value("R2_BUCKET")
  bucket.empty? ? "zentty" : bucket
end

def r2_public_base_url
  base_url = resolved_env_value("R2_PUBLIC_BASE_URL")
  base_url.empty? ? "https://releases.zentty.org" : base_url
end

def wrangler_r2_remote_flag_supported?
  return @wrangler_r2_remote_flag_supported unless @wrangler_r2_remote_flag_supported.nil?

  stdout, stderr, status = run_command("wrangler", "r2", "object", "put", "--help")
  help_output = "#{stdout}\n#{stderr}"
  @wrangler_r2_remote_flag_supported = status.success? && help_output.match?(/(?:\A|\s)--remote(?:\s|\z)/)
end

def wrangler_put_file!(local_path:, object_key:, content_type:, cache_control:)
  command = [
    "wrangler", "r2", "object", "put",
    "#{r2_bucket_name}/#{object_key}",
    "--file", local_path,
    "--content-type", content_type,
    "--cache-control", cache_control
  ]
  command << "--remote" if wrangler_r2_remote_flag_supported?
  sh(*command)
end

def upload_github_release_assets!(tag_name:, asset_paths:)
  paths = Array(asset_paths).select { |path| path && File.file?(path) }
  return if paths.empty?

  sh("gh", "release", "upload", tag_name, *paths, "--clobber")
end

def upsert_github_release!(tag_name:, version:, channel:, notes_path:, asset_paths: [])
  if github_release_exists?(tag_name)
    sh(
      "gh", "release", "edit", tag_name,
      "--title", release_title(version),
      "--notes-file", notes_path
    )
  else
    args = [
      "gh", "release", "create", tag_name,
      "--title", release_title(version),
      "--notes-file", notes_path,
      "--verify-tag"
    ]
    args << "--prerelease" if channel == "beta"
    args << "--latest" if channel == "stable"
    sh(*args)
  end

  upload_github_release_assets!(tag_name: tag_name, asset_paths: asset_paths)
end

def prepare_release_notes!(tag_name:, archive_basename:)
  previous_tag = previous_release_tag_for(tag_name)
  generated = generated_release_notes(tag_name: tag_name, previous_tag_name: previous_tag.to_s)
  initial_text = draft_release_notes_with_codex(
    tag_name: tag_name,
    previous_tag_name: previous_tag.to_s,
    fallback_text: generated
  )
  edited_notes_path = release_notes_path(archive_basename: archive_basename)
  edit_text_in_editor!(initial_text, path_hint: edited_notes_path)
  edited_notes_path
end

def stage_release_archive!(zip_path:)
  FileUtils.mkdir_p(sparkle_updates_dir)
  destination = File.join(sparkle_updates_dir, File.basename(zip_path))
  FileUtils.cp(zip_path, destination)
  destination
end

def sparkle_pin!
  @sparkle_pin ||= begin
    resolved_path = project_root_path(PACKAGE_RESOLVED)
    UI.user_error!("Missing #{PACKAGE_RESOLVED}; the pinned package resolution must be committed") unless File.file?(resolved_path)
    pin = ReleaseAutomation.sparkle_pin(File.read(resolved_path))
    UI.user_error!("#{PACKAGE_RESOLVED} does not pin Sparkle") unless pin
    pin
  end
end

def git_head_revision(path)
  return nil unless File.directory?(path)

  stdout, _, status = run_command("git", "-C", path, "rev-parse", "HEAD")
  status.success? ? stdout.strip : nil
end

def sparkle_checkout_path!
  pin = sparkle_pin!
  pinned_label = "Sparkle #{pin[:version]} (#{pin[:revision]})"

  explicit_path = ENV["SPARKLE_PACKAGE_PATH"].to_s.strip
  unless explicit_path.empty?
    head = git_head_revision(explicit_path)
    UI.user_error!("SPARKLE_PACKAGE_PATH is at #{head || "an unknown revision"}, but #{PACKAGE_RESOLVED} pins #{pinned_label}") unless head == pin[:revision]
    return explicit_path
  end

  candidates = [project_root_path(SOURCE_PACKAGES_DIR, "checkouts", "Sparkle")]
  candidates += Dir[File.join(Dir.home, "Library/Developer/Xcode/DerivedData", "*", "SourcePackages", "checkouts", "Sparkle")]
  candidates = candidates.map { |path| { path: path, head: git_head_revision(path) } }

  ReleaseAutomation.select_pinned_checkout(candidates, revision: pin[:revision]) || clone_pinned_sparkle_checkout!(pin)
end

def clone_pinned_sparkle_checkout!(pin)
  destination = ReleaseAutomation.sparkle_clone_dir(project_root: PROJECT_ROOT, revision: pin[:revision])
  return destination if git_head_revision(destination) == pin[:revision]

  UI.user_error!("Package.resolved pins Sparkle without a location") if pin[:location].empty?
  FileUtils.rm_rf(destination)
  FileUtils.mkdir_p(File.dirname(destination))
  UI.message("Cloning Sparkle #{pin[:version]} (#{pin[:revision]}) for generate_appcast...")
  capture_command!(
    "git", "clone", "--quiet", "--no-checkout", pin[:location], destination,
    error_message: "Failed to clone Sparkle from #{pin[:location]}"
  )
  capture_command!(
    "git", "-C", destination, "checkout", "--quiet", "--detach", pin[:revision],
    error_message: "Failed to check out pinned Sparkle revision #{pin[:revision]}"
  )
  destination
end

def sparkle_generate_appcast_binary!
  explicit_bin = ENV["SPARKLE_GENERATE_APPCAST_BIN"].to_s.strip
  return explicit_bin if !explicit_bin.empty? && File.executable?(explicit_bin)

  pin = sparkle_pin!
  build_dir = ReleaseAutomation.sparkle_tools_dir(project_root: PROJECT_ROOT, revision: pin[:revision])
  binary_path = File.join(build_dir, "generate_appcast")
  return binary_path if File.executable?(binary_path)

  checkout = sparkle_checkout_path!
  FileUtils.mkdir_p(build_dir)
  UI.message("Building Sparkle #{pin[:version]} generate_appcast tool (one-time per pinned revision, may take a minute)...")
  capture_command!(
    xcrun_path, "xcodebuild",
    "-project", File.join(checkout, "Sparkle.xcodeproj"),
    "-scheme", "generate_appcast",
    "-configuration", "Release",
    "CONFIGURATION_BUILD_DIR=#{build_dir}",
    "CODE_SIGNING_ALLOWED=NO",
    "CODE_SIGNING_REQUIRED=NO",
    # Sparkle's project targets 10.13; Xcode 27 only accepts 12.0+.
    "MACOSX_DEPLOYMENT_TARGET=13.0",
    "build",
    chdir: checkout,
    error_message: "Failed to build Sparkle generate_appcast tool"
  )
  UI.user_error!("Expected generate_appcast at #{binary_path}") unless File.executable?(binary_path)
  UI.success("Sparkle generate_appcast tool built")
  binary_path
end

def build_generate_appcast_arguments(channel:)
  args = [
    sparkle_generate_appcast_binary!,
    "--download-url-prefix", r2_public_base_url,
    "--release-notes-url-prefix", r2_public_base_url,
    "--maximum-versions", "0"
  ]
  args += ["--ed-key-file", sparkle_private_ed_key_file] unless sparkle_private_ed_key_file.empty?
  args += ["--account", sparkle_keychain_account] unless sparkle_keychain_account.empty?

  sparkle_channel = ReleaseAutomation.sparkle_channel(channel)
  args += ["--channel", sparkle_channel] if sparkle_channel
  args << sparkle_updates_dir
  args
end

def generate_sparkle_appcast!(channel:)
  FileUtils.mkdir_p(sparkle_updates_dir)
  UI.message("Generating Sparkle appcast (signing artifacts and building deltas, this can take a moment)...")
  started_at = Time.now
  capture_command!(*build_generate_appcast_arguments(channel: channel), error_message: "Failed to generate Sparkle appcast")
  UI.user_error!("Expected #{sparkle_appcast_path} to exist after appcast generation") unless File.exist?(sparkle_appcast_path)
  UI.success("Sparkle appcast ready (#{(Time.now - started_at).round(1)}s) -> #{sparkle_appcast_path}")
end

def r2_object_url(object_key)
  base = r2_public_base_url
  return nil if base.empty?

  "#{base.sub(%r{/+\z}, "")}/#{object_key}"
end

def r2_content_type_for_path(path)
  case File.extname(path).downcase
  when ".xml"
    "application/xml; charset=utf-8"
  when ".zip"
    "application/zip"
  when ".dmg"
    "application/x-apple-diskimage"
  when ".html"
    "text/html; charset=utf-8"
  when ".md", ".markdown"
    "text/markdown; charset=utf-8"
  when ".txt"
    "text/plain; charset=utf-8"
  else
    "application/octet-stream"
  end
end

def r2_cache_control_for_path(path)
  File.basename(path) == ReleaseAutomation.appcast_key ? R2_CACHE_NO : R2_CACHE_MAX
end

def upload_release_artifacts_to_r2!(dmg_path:, build_number:)
  staged_paths = Dir[File.join(sparkle_updates_dir, "*")].select { |path| File.file?(path) }
  fresh_paths = staged_paths.select { |path| release_artifact_for_build?(path, build_number) }
  skipped = staged_paths - fresh_paths
  UI.message("Skipping #{skipped.size} immutable historical artifact(s) already on R2") unless skipped.empty?

  (fresh_paths + [dmg_path]).each do |local_path|
    object_key = File.basename(local_path)
    wrangler_put_file!(
      local_path: local_path,
      object_key: object_key,
      content_type: r2_content_type_for_path(local_path),
      cache_control: r2_cache_control_for_path(local_path)
    )

    url = r2_object_url(object_key)
    UI.message("Uploaded #{object_key}#{url ? " -> #{url}" : ""}")
  end
end

def release_artifact_for_build?(path, build_number)
  basename = File.basename(path)
  return true if basename == ReleaseAutomation.appcast_key

  build = Regexp.escape(build_number.to_s)
  return true if basename =~ /\AZentty-[\d.]+-#{build}\.(zip|md)\z/
  return true if basename =~ /\AZentty#{build}-\d+\.delta\z/

  false
end

def run_local_bundle_pipeline
  generate_project!
  sync_signing!

  build_number = number_of_commits(all: true).to_s
  write_project_build_number(build_number: build_number)
  generate_project!

  sh(
    "git", "-C", PROJECT_ROOT,
    "commit", "--only",
    "-m", "build: bump build number to #{build_number}",
    "--", PROJECT_SPEC, PROJECT
  )

  archive_release!(version: read_project_marketing_version)
  notarize_app_bundle!
  package_release_dmg!
  notarize_release_dmg!
end

def run_release_pipeline(options)
  channel = determine_release_channel(options)
  version = determine_release_version(channel: channel, requested_version: options[:version])
  version = validate_release_inputs!(channel: channel, version: version)
  tag_name = release_tag_name(version)

  ensure_release_tools_ready!
  UI.message("Latest published stable version: #{latest_published_version(channel: "stable") || "none"}")
  UI.message("Current project version: #{read_project_marketing_version}")
  UI.message("Release channel: #{channel}")
  UI.message("Release version: #{version}")

  ensure_release_not_conflicting!(tag_name)
  prompt_release_confirmation(channel: channel, version: version)

  build_number = commit_release_metadata!(version: version)
  local_tag_release!(tag_name, version)

  sync_signing!
  archive_release!(version: version)
  upload_glitchtip_dsyms!
  notarize_app_bundle!
  package_release_zip!
  package_release_dmg!
  notarize_release_dmg!

  archive_basename = ENV["ZENTTY_ARCHIVE_BASENAME"] || ReleaseAutomation.archive_basename(app_name: APP_NAME, version: version, build: build_number)
  stage_release_archive!(zip_path: ENV.fetch("ZENTTY_ZIP_PATH"))
  notes_path = prepare_release_notes!(tag_name: tag_name, archive_basename: archive_basename)
  generate_sparkle_appcast!(channel: channel)
  upload_release_artifacts_to_r2!(dmg_path: ENV.fetch("ZENTTY_DMG_PATH"), build_number: build_number)
  push_release_tag!(tag_name)
  github_dmg_path = File.join(File.dirname(ENV.fetch("ZENTTY_DMG_PATH")), "#{APP_NAME}.dmg")
  FileUtils.cp(ENV.fetch("ZENTTY_DMG_PATH"), github_dmg_path)
  upsert_github_release!(
    tag_name: tag_name,
    version: version,
    channel: channel,
    notes_path: notes_path,
    asset_paths: [github_dmg_path]
  )
end

lane :bundle do
  run_local_bundle_pipeline
end

lane :release do |options|
  run_release_pipeline(options)
end

platform :mac do

  desc "Regenerate Xcode project from project.yml"
  lane :generate_project do
    generate_project!
  end

  desc "Set CURRENT_PROJECT_VERSION in project.yml to git commit count"
  lane :bump_build_number do
    build_number = number_of_commits(all: true).to_s
    write_project_build_number(build_number: build_number)
    UI.success("Build number set to #{build_number}")
  end

  desc "Commit the bumped build number"
  lane :commit_build_number do
    build_number = read_project_build_number
    sh(
      "git", "-C", PROJECT_ROOT,
      "commit", "--only",
      "-m", "build: bump build number to #{build_number}",
      "--", PROJECT_SPEC, PROJECT
    )
  end

  desc "Sync Developer ID certificate via match"
  lane :sync_signing do
    sync_signing!
  end

  desc "Archive and export with Developer ID signing"
  lane :archive do |options|
    version = options[:version] || read_project_marketing_version
    archive_release!(version: version)
  end

  desc "Upload archived dSYMs to GlitchTip"
  lane :upload_dsyms do |options|
    upload_glitchtip_dsyms!(upload_root: options[:path])
  end

  desc "Notarize the .app bundle"
  lane :notarize_app do
    notarize_app_bundle!
  end

  desc "Create Sparkle ZIP archive from the exported .app"
  lane :package_zip do
    package_release_zip!
  end

  desc "Create DMG from the exported .app"
  lane :package_dmg do
    package_release_dmg!
  end

  desc "Notarize the DMG"
  lane :notarize_dmg do
    notarize_release_dmg!
  end

  desc "Build Sparkle's generate_appcast from the revision pinned in Package.resolved"
  lane :build_generate_appcast do
    UI.success("generate_appcast ready -> #{sparkle_generate_appcast_binary!}")
  end

  desc "Build GhosttyKit.xcframework from source"
  lane :build_ghosttykit do
    sh("../scripts/build_ghosttykit.sh")
  end
end
