#!/usr/bin/env bash
# aptly-reconcile — converges a running aptly instance towards a declarative
# state.yaml document. Talks ONLY to the REST API, never to the aptly CLI:
# `aptly api serve` holds the LevelDB single-writer lock, and a CLI invocation
# against the same rootDir would either fail to acquire it or (with -no-lock)
# corrupt the database. Safe to run repeatedly (Helm post-install/post-upgrade
# hook, a plain Job, or by hand).
#
# Contract (state.yaml):
#
#   localRepos:
#     - name: internal-stable
#       comment: ""
#       defaultDistribution: stable
#       defaultComponent: main
#
#   mirrors:
#     - name: debian-trixie
#       archiveURL: http://deb.debian.org/debian
#       distribution: trixie
#       components: [main, contrib]
#       architectures: [amd64, arm64]
#       filter: ""
#       filterWithDeps: false
#       downloadSources: false
#       downloadUdebs: false
#       downloadInstaller: false
#       ignoreSignatures: false
#       keyrings: []          # usually empty: verification uses GNUPGHOME's
#                             # default keyring, already populated by aptly-init
#                             # from aptly.gpgKeys / state.gpgKeys.
#
#   publish:
#     - name: stable-root      # reconcile-local handle, never sent to the API
#       prefix: ""             # "" = repo root
#       distribution: stable
#       sourceKind: local      # local | snapshot
#       sources: [{name: internal-stable, component: main}]
#       architectures: [amd64, arm64]
#       acquireByHash: true
#       skipContents: false
#       skipBz2: false
#
# Known, deliberate limitation: components[] on an EXISTING mirror cannot be
# changed via the API (aptly has no such endpoint) — changing them requires
# dropping and recreating the mirror by hand. Everything else in a mirror
# definition, and all of a local repo's metadata, IS kept in sync on every run.
#
# Env:
#   APTLY_STATE_FILE     path to state.yaml (required)
#   APTLY_FAIL_ON_ERROR  "true"/"false" (default: false) — false logs and
#                        continues past a failed item instead of aborting the
#                        whole run (an unreachable upstream mirror must not
#                        break `helm upgrade`)
#   APTLY_DRY_RUN        "true"/"false" (default: false) — print the planned
#                        calls without making them
#   plus everything lib/common.sh's api()/wait_task() need (APTLY_URL, ...)
set -euo pipefail

SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=rootfs/usr/local/bin/lib/common.sh
source "${SCRIPT_DIR}/lib/common.sh"
require_cmd curl jq yq

: "${APTLY_STATE_FILE:?APTLY_STATE_FILE must point at a state.yaml}"
FAIL_ON_ERROR="${APTLY_FAIL_ON_ERROR:-false}"
DRY_RUN="${APTLY_DRY_RUN:-false}"
STATE_JSON="$(yq -o=json '.' "$APTLY_STATE_FILE")"

item_failed=0
on_error() {
  local what="$1"
  item_failed=1
  if [[ "$FAIL_ON_ERROR" == "true" ]]; then
    die "reconcile failed on: ${what}"
  else
    warn "reconcile step failed, continuing (APTLY_FAIL_ON_ERROR=false): ${what}"
  fi
}

do_api() {
  # do_api METHOD PATH JSON_BODY -> prints body, returns curl's exit status
  local method="$1" path="$2" body="$3"
  if [[ "$DRY_RUN" == "true" ]]; then
    log "[dry-run] ${method} ${path} ${body}"
    printf '{}'
    return 0
  fi
  api "$method" "$path" -H 'Content-Type: application/json' -d "$body"
}

api_ready

# ---------------------------------------------------------------------------
# Local repos
# ---------------------------------------------------------------------------
repo_count="$(jq '(.localRepos // []) | length' <<<"$STATE_JSON")"
for ((i=0; i<repo_count; i++)); do
  repo="$(jq ".localRepos[$i]" <<<"$STATE_JSON")"
  name="$(jq -r '.name' <<<"$repo")"
  body="$(jq '{Name: .name, Comment: (.comment // ""), DefaultDistribution: (.defaultDistribution // ""), DefaultComponent: (.defaultComponent // "")}' <<<"$repo")"

  code_body="$(api_status GET "/api/repos/${name}")"
  code="$(head -1 <<<"$code_body")"
  if [[ "$code" == "404" ]]; then
    log "creating local repo '${name}'"
    do_api POST "/api/repos" "$body" >/dev/null || on_error "create local repo ${name}"
  else
    log "syncing local repo '${name}' metadata"
    do_api PUT "/api/repos/${name}" "$body" >/dev/null || on_error "edit local repo ${name}"
  fi
done

# ---------------------------------------------------------------------------
# Mirrors
# ---------------------------------------------------------------------------
mirror_count="$(jq '(.mirrors // []) | length' <<<"$STATE_JSON")"
for ((i=0; i<mirror_count; i++)); do
  m="$(jq ".mirrors[$i]" <<<"$STATE_JSON")"
  name="$(jq -r '.name' <<<"$m")"

  code_body="$(api_status GET "/api/mirrors/${name}")"
  code="$(head -1 <<<"$code_body")"
  if [[ "$code" == "404" ]]; then
    body="$(jq '{
      Name: .name, ArchiveURL: .archiveURL, Distribution: .distribution,
      Filter: (.filter // ""), Components: (.components // []),
      Architectures: (.architectures // []), Keyrings: (.keyrings // []),
      DownloadSources: (.downloadSources // false), DownloadUdebs: (.downloadUdebs // false),
      DownloadInstaller: (.downloadInstaller // false), DownloadAppStream: (.downloadAppStream // false),
      FilterWithDeps: (.filterWithDeps // false), IgnoreSignatures: (.ignoreSignatures // false)
    }' <<<"$m")"
    log "creating mirror '${name}'"
    do_api POST "/api/mirrors" "$body" >/dev/null || on_error "create mirror ${name}"
  else
    body="$(jq '{
      ArchiveURL: .archiveURL, Filter: (.filter // ""),
      Architectures: (.architectures // []), Keyrings: (.keyrings // []),
      DownloadSources: (.downloadSources // false), DownloadUdebs: (.downloadUdebs // false),
      DownloadInstaller: (.downloadInstaller // false),
      FilterWithDeps: (.filterWithDeps // false), IgnoreSignatures: (.ignoreSignatures // false)
    }' <<<"$m")"
    log "syncing mirror '${name}' definition (note: components[] cannot be changed post-creation)"
    do_api POST "/api/mirrors/${name}" "$body" >/dev/null || on_error "edit mirror ${name}"
  fi
done

# ---------------------------------------------------------------------------
# Publish targets
# ---------------------------------------------------------------------------
existing_publishes="$(api GET "/api/publish")"

publish_count="$(jq '(.publish // []) | length' <<<"$STATE_JSON")"
for ((i=0; i<publish_count; i++)); do
  p="$(jq ".publish[$i]" <<<"$STATE_JSON")"
  handle="$(jq -r '.name' <<<"$p")"
  prefix="$(jq -r '.prefix // ""' <<<"$p")"
  dist="$(jq -r '.distribution' <<<"$p")"
  escaped_prefix="$(api_prefix "$prefix")"

  # GET /api/publish returns the root prefix as the literal string "." (not
  # ""), matching aptly's own storage convention — confirmed empirically, see
  # docs/packaging.md. Nested prefixes are returned with real slashes, not
  # underscore-escaped, so only the root case needs translating here.
  prefix_for_compare="$prefix"
  [[ -z "$prefix_for_compare" ]] && prefix_for_compare="."

  found="$(jq --arg prefix "$prefix_for_compare" --arg dist "$dist" \
    '[.[] | select(.Prefix == $prefix and .Distribution == $dist)] | length > 0' \
    <<<"$existing_publishes")"

  sources="$(jq '[.sources[] | {Component: .component, Name: .name}]' <<<"$p")"
  signing="$(jq -n --slurpfile sj <(cat "${APTLY_SIGNING_JSON:-/run/aptly/signing.json}" 2>/dev/null || echo '{"skip":true}') '
    ($sj[0] // {skip:true}) as $s
    | if $s.skip then {Skip: true}
      else {Skip: false, GpgKey: ($s.gpgKey // ""), PassphraseFile: ($s.passphraseFile // "")} end')"

  if [[ "$found" != "true" ]]; then
    body="$(jq -n --argjson sources "$sources" --argjson signing "$signing" --argjson p "$p" '
      {
        SourceKind: ($p.sourceKind // "local"),
        Sources: $sources,
        Distribution: $p.distribution,
        Architectures: ($p.architectures // []),
        AcquireByHash: ($p.acquireByHash // false),
        SkipContents: ($p.skipContents // false),
        SkipBz2: ($p.skipBz2 // false),
        Signing: $signing
      }')"
    log "creating publish '${handle}' at prefix='${prefix}' distribution='${dist}'"
    do_api POST "/api/publish/${escaped_prefix}" "$body" >/dev/null || on_error "create publish ${handle}"
  else
    kind="$(jq -r '.sourceKind // "local"' <<<"$p")"
    if [[ "$kind" == "local" ]]; then
      body="$(jq -n --argjson signing "$signing" --argjson p "$p" '
        { Signing: $signing, AcquireByHash: ($p.acquireByHash // false),
          SkipContents: ($p.skipContents // false), SkipBz2: ($p.skipBz2 // false) }')"
      log "refreshing publish '${handle}' (local repo -> re-publish pending changes)"
      resp="$(do_api POST "/api/publish/${escaped_prefix}/${dist}/update" "$body")" || { on_error "update publish ${handle}"; continue; }
    else
      body="$(jq -n --argjson sources "$sources" --argjson signing "$signing" --argjson p "$p" '
        { Snapshots: $sources, Signing: $signing, AcquireByHash: ($p.acquireByHash // false),
          SkipContents: ($p.skipContents // false), SkipBz2: ($p.skipBz2 // false) }')"
      log "switching publish '${handle}' to configured snapshot(s)"
      resp="$(do_api PUT "/api/publish/${escaped_prefix}/${dist}" "$body")" || { on_error "switch publish ${handle}"; continue; }
    fi
    if [[ "$DRY_RUN" != "true" ]]; then
      task_id="$(jq -r '.ID // empty' <<<"$resp")"
      [[ -n "$task_id" ]] && { wait_task "$task_id" || on_error "task for publish ${handle}"; }
    fi
  fi
done

if [[ "$item_failed" -eq 1 ]]; then
  log "completed with warnings (APTLY_FAIL_ON_ERROR=false) — see above"
else
  log "reconcile completed successfully"
fi
