#!/bin/sh
# Plan an offline image update against the customer's existing Compose sources.
# Compose interprets configuration; bundled yq edits only planned image scalars.
# Database/data backup and schema rollback remain the operator's responsibility.
set -eu
set -f
umask 077

fail() { printf '错误：%s\n' "$*" >&2; exit 1; }
quote_argument() { printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\\\\''/g")"; }

install_dir= project= config_files= environment_files=
dry_run=0
while [ "$#" -gt 0 ]; do
    case "$1" in
        --dir) [ "$#" -ge 2 ] && [ -n "$2" ] || fail '--dir 需要目录参数'; install_dir=$2; shift 2 ;;
        --dry-run) dry_run=1; shift ;;
        *) fail "未知参数：$1（用法：sh install-asktable.sh [--dir 目录] [--dry-run]）" ;;
    esac
done
case "$install_dir" in ""|/*) ;; *) install_dir=$PWD/$install_dir ;; esac
explicit_dir=$install_dir
script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
architecture=$(docker info --format '{{.Architecture}}') || fail '无法连接 Docker'
case "$architecture" in
    x86_64|amd64) architecture=amd64; expected_hash='a15e694b1cd7c26ba07aff78a6eb5e7f53af2f386e758c7b5bbea45dc212ea6d' ;;
    aarch64|arm64) architecture=arm64; expected_hash='935ae434573a3493e08f265b5073d7e6724257ccb7453ccfc83fde6c5327f32f' ;;
    *) fail "不支持的 Docker 架构：$architecture" ;;
esac
# Select one executable meeting the minimum version, then keep that entrypoint
# for configuration, execution and every command handed back to the operator.
compose_command= compose_versions=
for compose_candidate in docker docker-compose; do
    if [ "$compose_candidate" = docker ]; then
        compose_label='docker compose'
        compose_version=$(docker compose version --short 2>/dev/null) || continue
    else
        compose_label=docker-compose
        compose_version=$(docker-compose version --short 2>/dev/null) || continue
    fi
    if printf '%s\n' "$compose_version" | awk '
        {sub(/^v/, ""); split($0,n,"."); exit !(n[1]>2 || (n[1]==2 && (n[2]>23 || (n[2]==23 && n[3]>=1))))}
    '; then
        compose_command=$compose_candidate
        break
    fi
    compose_versions="${compose_versions}${compose_label}: ${compose_version}; "
done
if [ -z "$compose_command" ]; then
    if [ -n "$compose_versions" ]; then
        fail "Docker Compose 版本过旧（${compose_versions}），需要 2.23.1 或更新版本"
    fi
    fail '未找到可用的 Docker Compose，请检查 docker compose 或 docker-compose 命令'
fi
archive=$script_dir/asktable-offline-linux-$architecture.tar.gz
# Large archives stay on the delivery disk, never on a possibly small /tmp tmpfs.
work=$(mktemp -d "$script_dir/.asktable-install.XXXXXX")
# yq in-place edits also need scratch space on the delivery disk.
TMPDIR=$work
export TMPDIR
lock= write_temp= record_dir=
cleanup() {
    result=$?
    [ -z "$write_temp" ] || rm -f "$write_temp"
    [ -z "$lock" ] || rm -rf "$lock"
    rm -rf "$work"
    exit "$result"
}
trap cleanup EXIT
trap 'exit 1' HUP INT TERM

# Discovery intentionally ignores containers without Compose deployment labels.
# Existing records already identify the project; do not authenticate it again.
: > "$work/deployments"
docker ps -a -q > "$work/containers" || fail '无法读取已有容器'
while IFS= read -r container; do
    [ -n "$container" ] || continue
    record=$(docker inspect --format '{{.Config.Image}}{{"\t"}}{{with index .Config.Labels "com.docker.compose.project"}}{{.}}{{end}}{{"\t"}}{{with index .Config.Labels "com.docker.compose.project.working_dir"}}{{.}}{{end}}{{"\t"}}{{with index .Config.Labels "com.docker.compose.project.config_files"}}{{.}}{{end}}{{"\t"}}{{with index .Config.Labels "com.docker.compose.project.environment_file"}}{{.}}{{end}}' "$container") || fail '无法读取容器部署记录'
    image=$(printf '%s\n' "$record" | cut -f1)
    image=${image%%@*}; image=${image##*/}; image=${image%%:*}
    [ "$image" = asktable-all-in-one ] || continue
    found_project=$(printf '%s\n' "$record" | cut -f2)
    found_dir=$(printf '%s\n' "$record" | cut -f3)
    [ -n "$found_project" ] && [ -n "$found_dir" ] || continue
    if [ -n "$explicit_dir" ]; then
        [ -d "$explicit_dir" ] && [ -d "$found_dir" ] || continue
        [ "$(cd "$found_dir" && pwd -P)" = "$(cd "$explicit_dir" && pwd -P)" ] || continue
    fi
    printf '%s\n' "$record" | cut -f2- >> "$work/deployments"
done < "$work/containers"
awk -F '\t' '!seen[$1 FS $2]++' "$work/deployments" > "$work/unique-deployments"
count=$(wc -l < "$work/unique-deployments" | tr -d ' ')
case "$count" in
    0) install_dir=${install_dir:-/opt/asktable} ;;
    1) project=$(cut -f1 "$work/unique-deployments"); install_dir=$(cut -f2 "$work/unique-deployments"); config_files=$(cut -f3 "$work/unique-deployments"); environment_files=$(cut -f4 "$work/unique-deployments") ;;
    *) printf '发现多个 AskTable 部署，未导入镜像或修改部署：\n' >&2; cut -f1,2 "$work/unique-deployments" >&2; exit 1 ;;
esac
[ -f "$archive" ] || fail "未找到对应架构的离线包：$archive"
if command -v sha256sum >/dev/null 2>&1; then
    actual_hash=$(sha256sum "$archive" | awk '{print $1}')
else
    actual_hash=$(shasum -a 256 "$archive" | awk '{print $1}')
fi
[ "$actual_hash" = "$expected_hash" ] || fail '离线包校验失败，请重新取得同次发布的脚本和压缩包'
# Previews need metadata and the parser only, not the multi-gigabyte images.tar.
tar -xzf "$archive" -C "$work" images.tsv release.json docker-compose.yaml yq
yq=$work/yq
version=$("$yq" -r '.version' "$work/release.json")

# Keep argument lists as lines: spaces in paths must not become shell arguments.
: > "$work/files"
candidate= edited_file=
compose() (
    print_command=0
    if [ "${1:-}" = --print-command ]; then print_command=1; shift; fi
    reversed=$(awk '{a[NR]=$0} END {for(i=NR;i>0;i--) print a[i]}' "$work/files")
    while IFS= read -r file; do
        [ -n "$file" ] || continue
        if [ -n "$candidate" ] && [ "$file" = "$edited_file" ]; then file=$candidate; fi
        if [ "$print_command" = 1 ] && [ "${fresh:-0}" = 1 ] && [ "$file" = "$work/docker-compose.yaml" ]; then
            file=$install_dir/docker-compose.yaml
        fi
        set -- -f "$file" "$@"
    done <<EOF
$reversed
EOF
    if [ -n "$environment_files" ]; then
        reversed=$(printf '%s' "$environment_files" | awk -F, '{for(i=NF;i>0;i--) print $i}')
        while IFS= read -r file; do set -- --env-file "$file" "$@"; done <<EOF
$reversed
EOF
    fi
    [ -z "$project" ] || set -- --project-name "$project" "$@"
    set -- --project-directory "$install_dir" "$@"
    if [ "$compose_command" = docker ]; then
        set -- docker compose "$@"
    else
        set -- docker-compose "$@"
    fi
    if [ "$print_command" = 1 ]; then
        printf '(cd '; quote_argument "$install_dir"; printf ' && '
        for argument do quote_argument "$argument"; printf ' '; done
        printf ')\n'
    else
        if [ -d "$install_dir" ]; then cd "$install_dir"; else cd "$script_dir"; fi
        "$@"
    fi
)

if [ -n "$config_files" ]; then
    printf '%s\n' "$config_files" | tr ',' '\n' > "$work/files"
elif [ -d "$install_dir" ]; then
    # Ask Compose itself for dotenv interpolation; do not parse or source .env.
    cat > "$work/files-probe.yaml" <<'EOF'
name: asktable_offline_probe
services:
  metadata:
    image: scratch
    environment:
      FILES: ${COMPOSE_FILE:-}
      SEPARATOR: ${COMPOSE_PATH_SEPARATOR:-:}
EOF
    compose -f "$work/files-probe.yaml" config --format json > "$work/files-probe.json" || fail '无法读取 Compose 文件配置'
    files=$("$yq" -r '.services.metadata.environment.FILES // ""' "$work/files-probe.json")
    separator=$("$yq" -r '.services.metadata.environment.SEPARATOR' "$work/files-probe.json")
    if [ -n "$files" ]; then
        FILES=$files SEPARATOR=$separator "$yq" -n -r 'strenv(FILES) | split(strenv(SEPARATOR)) | .[]' > "$work/files"
    else
        for file in compose.yaml compose.yml docker-compose.yml docker-compose.yaml; do
            if [ -f "$install_dir/$file" ]; then printf '%s\n' "$file" >> "$work/files"; break; fi
        done
        if [ -s "$work/files" ]; then
            for file in compose.override.yml compose.override.yaml docker-compose.override.yml docker-compose.override.yaml; do
                if [ -f "$install_dir/$file" ]; then printf '%s\n' "$file" >> "$work/files"; break; fi
            done
        fi
    fi
fi
fresh=0
if [ ! -s "$work/files" ]; then
    fresh=1
    if [ -d "$install_dir" ] && [ -n "$(ls -A "$install_dir")" ]; then fail "首次安装目录非空：$install_dir"; fi
    printf '%s\n' "$work/docker-compose.yaml" > "$work/files"
else
    : > "$work/absolute-files"
    while IFS= read -r file; do
        case "$file" in *://*) fail "不支持远程 Compose 文件：$file" ;; /*) ;; *) file=$install_dir/$file ;; esac
        [ -f "$file" ] || fail "Compose 文件不存在：$file"
        printf '%s\n' "$file" >> "$work/absolute-files"
    done < "$work/files"
    mv "$work/absolute-files" "$work/files"
fi
# Ordinary local file chains are supported. External composition needs a known
# local write location, and must not cause network access during configuration.
while IFS= read -r file; do
    composition=$("$yq" -r 'has("include") or ([.services[].extends | select(. != null)] | length > 0)' "$file")
    if [ "$composition" = true ]; then
        fail "配置使用 include/extends，无法确定本地写回位置，请先整理配置：$file"
    fi
done < "$work/files"
printf '部署目录：%s\n' "$install_dir"
compose config --format json > "$work/original.json" || fail '无法解析部署服务配置'
[ -n "$project" ] || project=$("$yq" -r '.name' "$work/original.json")
"$yq" -r '.services | to_entries | .[] | select(.value.image != null) | [.key, .value.image] | @tsv' "$work/original.json" > "$work/services.tsv"
[ -s "$work/services.tsv" ] || fail '未找到可更新的服务镜像'

# A tag separator belongs to the final path component, never the registry port.
parse_reference() {
    ref_repository=$1
    ref_tag=
    ref_last=${1##*/}
    case "$ref_last" in *:*) ref_repository=${1%:*}; ref_tag=${ref_last##*:} ;; esac
}
database_major() {
    case "$1" in
        postgres) printf '%s' "$2" | sed -n 's/^\([0-9][0-9]*\).*/\1/p' ;;
        pgvector)
            printf '%s\n' "$2" | awk -F- '
                {for(i=1;i<=NF;i++) if($i ~ /^pg[0-9]+$/) {major=substr($i,3); count++}}
                END {if(count==1) print major}
            '
            ;;
    esac
}
resolve_source() (
    source_path=$1
    while [ -L "$source_path" ]; do
        directory=$(CDPATH= cd -- "$(dirname -- "$source_path")" && pwd -P)
        target_path=$(readlink "$source_path")
        case "$target_path" in
            /*) source_path=$target_path ;;
            *) source_path=$directory/$target_path ;;
        esac
    done
    directory=$(CDPATH= cd -- "$(dirname -- "$source_path")" && pwd -P)
    printf '%s/%s\n' "$directory" "$(basename -- "$source_path")"
)
version_tag() { printf '%s\n' "$1" | grep -Eq '^(v?[0-9]+(\.[0-9]+)*|pg[0-9]+)([-_][A-Za-z0-9_.-]+)?$'; }
: > "$work/plan.tsv"
changes=0 main_matched=0
if [ "$count" = 0 ] && [ "$fresh" = 0 ]; then
    awk -F '\t' '{ref=$2; sub(/@.*/, "", ref); sub(/.*\//, "", ref); sub(/:.*/, "", ref); if(ref=="asktable-all-in-one") found=1} END {exit !found}' "$work/services.tsv" || fail '目录没有可明确匹配的 AskTable 主服务，未修改或启动该项目'
fi
tab=$(printf '\t')
while IFS="$tab" read -r service current; do
    match=$(awk -F '\t' -v current="$current" -v service="$service" '
        function repository(ref) {sub(/@.*/, "", ref); sub(/:[^\/]*$/, "", ref); return ref}
        function basename(ref) {ref=repository(ref); sub(/.*\//, "", ref); return ref}
        {
            if (basename($3) != basename(current)) next
            pair=$2 "\t" $3
            if ($3 == current) exact=pair
            if (!seen[pair]++) {names++; named=pair}
            if (repository($3) == repository(current) && !repos[pair]++) {repositories++; repo=pair}
            if ($1 == service) by_service=pair
        }
        END {
            if (exact != "") print exact
            else if (repositories == 1) print repo
            else if (by_service != "") print by_service
            else if (names == 1) print named
            else if (names > 1) exit 2
        }
    ' "$work/images.tsv") || fail "无法唯一匹配服务 ${service} 的镜像：$current"
    if [ -z "$match" ]; then
        docker image inspect "$current" >/dev/null 2>&1 || fail "额外服务 ${service} 所需镜像不在本地：$current"
        continue
    fi
    archive_reference=$(printf '%s\n' "$match" | cut -f1)
    published=$(printf '%s\n' "$match" | cut -f2)
    case "$current" in *@*) fail "不支持自动更新 digest 固定引用：$current" ;; esac
    parse_reference "$current"; repository=$ref_repository; current_tag=$ref_tag
    parse_reference "$published"; published_tag=$ref_tag; component=${ref_repository##*/}
    [ "$component" != asktable-all-in-one ] || main_matched=1
    case "$component" in
        postgres|pgvector)
            old_major=$(database_major "$component" "$current_tag")
            new_major=$(database_major "$component" "$published_tag")
            [ -n "$old_major" ] && [ "$old_major" = "$new_major" ] || fail "数据库大版本不兼容或无法确定：$current → ${published}；请先处理数据库迁移"
            ;;
    esac
    case "$current_tag" in
        ""|latest) target=$current ;;
        *)
            version_tag "$current_tag" || fail "无法确定自定义标签的版本含义：$current"
            version_tag "$published_tag" || fail "组件没有明确的新版本：${published}（当前 ${current}）"
            target=$repository:$published_tag
            ;;
    esac
    [ "$target" = "$current" ] || changes=$((changes + 1))
    printf '%s\t%s\t%s\t%s\t%s\n' "$service" "$current" "$target" "$archive_reference" "$published" >> "$work/plan.tsv"
done < "$work/services.tsv"
if [ "$count" = 0 ] && [ "$fresh" = 0 ] && [ "$main_matched" = 0 ]; then
    fail '指定目录没有可明确匹配的 AskTable 主服务，未修改或启动该项目'
fi
[ -s "$work/plan.tsv" ] || fail '没有可更新的受管理服务'

# Change one already-loaded local source. Never replace it with canonical config:
# that would freeze variables, secrets and relative paths across the whole file.
cp "$work/original.json" "$work/expected.json"
if [ "$changes" -gt 0 ]; then
    edited_file=$(tail -n 1 "$work/files")
    write_source=$(resolve_source "$edited_file")
    cp -p "$edited_file" "$work/compose.before.yaml"
    cp -p "$edited_file" "$work/compose.candidate.yaml"
fi
while IFS="$tab" read -r service current target archive_reference published; do
    SERVICE=$service IMAGE=$target "$yq" -i '.services[strenv(SERVICE)].image = strenv(IMAGE)' "$work/expected.json"
    [ "$target" != "$current" ] || continue
    raw_image=
    while IFS= read -r file; do
        value=$(SERVICE=$service "$yq" -r '.services[strenv(SERVICE)].image // ""' "$file")
        [ -z "$value" ] || raw_image=$value
    done < "$work/files"
    case "$raw_image" in *'$'*) fail "服务 ${service} 的 image 使用变量，需要手动更新为 ${target}；未修改配置" ;; esac
    SERVICE=$service IMAGE=$target "$yq" -i '.services[strenv(SERVICE)].image = strenv(IMAGE)' "$work/compose.candidate.yaml"
done < "$work/plan.tsv"
if [ "$changes" -gt 0 ]; then
    candidate=$work/compose.candidate.yaml
    compose config --format json > "$work/candidate.json" || fail '候选 Compose 配置无效'
    candidate=
    "$yq" -o=json -I=0 'sort_keys(..)' "$work/expected.json" > "$work/expected.sorted"
    "$yq" -o=json -I=0 'sort_keys(..)' "$work/candidate.json" > "$work/candidate.sorted"
    cmp -s "$work/expected.sorted" "$work/candidate.sorted" || fail '候选配置改变了计划之外的配置，未执行升级'
fi
# Only concrete resource conflicts block a new installation. Unlabelled
# containers do not prove a deployment exists and are never used as identity.
printf "[\n" > "$work/container-info.json"
separator=
while IFS= read -r container; do
    [ -n "$container" ] || continue
    printf '%s\n' "$separator" >> "$work/container-info.json"
    docker inspect --format '{{json .}}' "$container" >> "$work/container-info.json"
    separator=,
done < "$work/containers"
printf "]\n" >> "$work/container-info.json"
if [ "$fresh" = 1 ]; then
    "$yq" -r '.services[].container_name // ""' "$work/original.json" > "$work/requested-names"
    "$yq" -r '.[].Name | sub("^/", "")' "$work/container-info.json" > "$work/existing-names"
    while IFS= read -r name; do
        [ -n "$name" ] || continue
        grep -Fx "$name" "$work/existing-names" >/dev/null && fail "容器名称已被占用：$name"
    done < "$work/requested-names"
    "$yq" -r '.services[].ports[] | select(.published != null) | [.protocol // "tcp", .host_ip // "*", .published] | @tsv' "$work/original.json" > "$work/requested-ports"
    "$yq" -r '.[].NetworkSettings.Ports | to_entries | .[] | .key as $port | .value[] | [($port | split("/") | .[1]), .HostIp, .HostPort] | @tsv' "$work/container-info.json" > "$work/existing-ports"
    if command -v ss >/dev/null 2>&1; then
        ss -H -ltnu > "$work/listeners" || fail '无法读取宿主机监听端口'
        awk '{protocol=$1; sub(/6$/, "", protocol); address=$5; port=address; sub(/.*:/,"",port); sub(/:[^:]*$/,"",address); gsub(/[\[\]]/,"",address); print protocol "\t" address "\t" port}' "$work/listeners" >> "$work/existing-ports"
    else
        printf '提示：未找到 ss，未执行宿主机监听端口预检；Docker 将在启动时检查最终端口绑定。\n'
    fi
    awk -F '\t' '
        function overlap(a,b, aa,bb) {
            split(a,aa,"-"); split(b,bb,"-")
            if(aa[2]=="") aa[2]=aa[1]
            if(bb[2]=="") bb[2]=bb[1]
            return aa[1]+0<=bb[2]+0 && bb[1]+0<=aa[2]+0
        }
        function address(a,b) {
            return a==b || a=="*" || b=="*" || \
                (a=="0.0.0.0" && b !~ /:/) || (b=="0.0.0.0" && a !~ /:/) || \
                (a=="::" && b ~ /:/) || (b=="::" && a ~ /:/)
        }
        FILENAME==ARGV[1] {protocol[NR]=$1; ip[NR]=$2; port[NR]=$3; count=NR; next}
        {
            for(i=1;i<=count;i++) {
                if(protocol[i]==$1 && address(ip[i],$2) && overlap(port[i],$3)) {
                    print "端口已被占用：" protocol[i] " " ip[i] ":" port[i]
                    conflict=1
                }
            }
        }
        END {exit conflict}
    ' "$work/requested-ports" "$work/existing-ports" > "$work/port-conflicts" || fail "$(cat "$work/port-conflicts")"
fi
# Re-tagging shared references affects later starts of other projects. Report
# known Compose users without guessing which unlabeled workloads belong to us.
"$yq" -r '.[] | [.Config.Labels."com.docker.compose.project" // "", .Config.Image] | @tsv' "$work/container-info.json" > "$work/shared-images"
awk -F '\t' -v project="$project" '
    function canonical(ref,first) {
        if(ref !~ /:[^\/]+$/ && ref !~ /@/) ref=ref ":latest"
        sub(/^index.docker.io\//,"docker.io/",ref)
        first=ref
        sub(/\/.*/,"",first)
        if(ref !~ /\//) ref="docker.io/library/" ref
        else if(first !~ /[.:]/ && first!="localhost") ref="docker.io/" ref
        if(ref ~ /^docker.io\/[^\/]+$/) sub(/^docker.io\//,"docker.io/library/",ref)
        return ref
    }
    FILENAME==ARGV[1] {target[canonical($3)]=$3; next}
    $1!="" && $1!=project && canonical($2) in target {print "提示：项目 " $1 " 也使用镜像 " target[canonical($2)] "；更新标签会影响该项目下次启动。"}
' "$work/plan.tsv" "$work/shared-images"

printf '项目：%s；离线包版本：%s\n' "$project" "$version"
while IFS="$tab" read -r service current target archive_reference published; do
    printf '  %s: %s → %s\n' "$service" "$current" "$target"
done < "$work/plan.tsv"
if [ "$changes" = 0 ]; then
    printf '镜像引用保持不变，将导入本次发布的镜像内容。\n'
else
    printf '将更新配置：%s\n' "$edited_file"
fi
printf '本次启动命令：\n'
compose --print-command up -d --pull never --no-build
if [ "$dry_run" = 1 ]; then
    printf '预览完成，未导入镜像、修改客户配置或启动服务。\n'
    exit 0
fi

# One lock per daemon because tags are shared across all deployment directories.
daemon_id=$(docker info --format '{{.ID}}')
lock_path=/tmp/asktable-offline-$daemon_id.lock
if ! mkdir "$lock_path" 2>/dev/null; then
    holder=$(cat "$lock_path/pid" 2>/dev/null || printf '未知')
    fail "同一 Docker 已有离线安装锁（PID ${holder}）：${lock_path}；确认进程已结束后手动清理，脚本不会接管锁"
fi
lock=$lock_path
printf '%s\n' "$$" > "$lock/pid"
compose config --format json > "$work/rechecked.json" || fail '获取锁后无法重新读取部署配置'
"$yq" -o=json -I=0 'sort_keys(..)' "$work/original.json" > "$work/original.sorted"
"$yq" -o=json -I=0 'sort_keys(..)' "$work/rechecked.json" > "$work/rechecked.sorted"
cmp -s "$work/original.sorted" "$work/rechecked.sorted" || fail '部署配置已在规划后变化，请重新执行'
if [ "$changes" -gt 0 ]; then
    [ "$(resolve_source "$edited_file")" = "$write_source" ] || fail '配置源文件的链接目标已变化，请重新执行'
    cmp -s "$edited_file" "$work/compose.before.yaml" || fail '配置源文件已在规划后变化，请重新执行'
fi
if [ "$fresh" = 1 ] && [ -d "$install_dir" ] && [ -n "$(ls -A "$install_dir")" ]; then fail '首次安装目录在规划后已变为非空，请重新检查'; fi

tar -xzf "$archive" -C "$work" images.tar
docker load -i "$work/images.tar" || fail '镜像导入失败，未启动服务'
while IFS="$tab" read -r service archive_reference published; do
    docker image inspect "$archive_reference" >/dev/null || fail "缺少导入镜像：$archive_reference"
done < "$work/images.tsv"
# The archive imports isolated references, so previous local customer tags are
# still intact here. Save their same-machine IDs before the first target re-tag.
run_id=$(date -u '+%Y%m%dT%H%M%SZ')-$$
record_dir=$install_dir/.asktable-offline/$run_id
mkdir -p "$record_dir"
cp "$work/plan.tsv" "$record_dir/plan.tsv"
restore=$record_dir/restore.sh
printf '#!/bin/sh\n# 仅恢复本机升级前的配置和本地标签，不代表升级前正在运行的镜像。\n# 不恢复数据库或业务数据；数据库迁移需单独处理。\nset -eu\n' > "$restore"
printf '恢复记录：%s（未备份数据库与业务数据）\n' "$record_dir"
awk -F '\t' '{if(!seen[$2]++) print $2; if(!seen[$3]++) print $3}' "$work/plan.tsv" > "$work/recovery-references"
index=0
while IFS= read -r reference; do
    index=$((index + 1))
    if old_id=$(docker image inspect --format '{{.Id}}' "$reference" 2>/dev/null); then
        recovery_reference=asktable-offline-recovery/image:$run_id-$index
        docker tag "$old_id" "$recovery_reference"
        printf '%s\t%s\t%s\n' "$reference" "$old_id" "$recovery_reference" >> "$record_dir/local-tags.tsv"
        printf 'docker tag ' >> "$restore"
        quote_argument "$recovery_reference" >> "$restore"
        printf ' ' >> "$restore"
        quote_argument "$reference" >> "$restore"
        printf '\n' >> "$restore"
    else
        printf '%s\t%s\n' "$reference" absent >> "$record_dir/local-tags.tsv"
        printf 'docker image rm ' >> "$restore"
        quote_argument "$reference" >> "$restore"
        printf ' >/dev/null 2>&1 || true\n' >> "$restore"
    fi
done < "$work/recovery-references"
if [ "$fresh" = 0 ]; then
    if [ "$changes" -gt 0 ]; then
        cp -p "$work/compose.before.yaml" "$record_dir/compose.before.yaml"
        printf 'cp -p ' >> "$restore"
        quote_argument "$record_dir/compose.before.yaml" >> "$restore"
        printf ' ' >> "$restore"
        quote_argument "$write_source" >> "$restore"
        printf '\n' >> "$restore"
    fi
    compose --print-command up -d --pull never --no-build >> "$restore"
else
    printf '# 首次安装没有旧配置或旧部署可重启。\n' >> "$restore"
fi
chmod 700 "$restore"

while IFS="$tab" read -r service current target archive_reference published; do
    docker tag "$archive_reference" "$target" || fail "无法设置目标镜像：$target"
done < "$work/plan.tsv"
if [ "$fresh" = 1 ]; then
    mkdir -p "$install_dir"
    cp "$work/docker-compose.yaml" "$install_dir/docker-compose.yaml"
    printf '%s\n' "$install_dir/docker-compose.yaml" > "$work/files"
elif [ "$changes" -gt 0 ]; then
    write_temp=$(mktemp "$(dirname "$write_source")/.asktable-compose.XXXXXX")
    cp -p "$work/compose.candidate.yaml" "$write_temp"
    mv "$write_temp" "$write_source"
    write_temp=
fi
compose up -d --pull never --no-build || fail '服务启动失败，请检查 Docker Compose 日志'
printf 'Compose 启动命令已完成；业务状态需按实际服务检查。\n恢复网络后可沿用原配置在线更新：\n'
compose --print-command pull
compose --print-command up -d
