// SPEC: pikasTech/unidesk#2128 PikaOA production attachments YAML-first backup.
import type { UniDeskConfig } from "./config";
import { rootPath } from "./config";
import { renderMachine } from "./cicd-render";
import { startJob } from "./jobs";
import type { RenderedCliResult } from "./output";
import { CliInputError } from "./output";
import { capture, compactCapture, readYamlRecord, sha256Fingerprint, shQuote } from "./platform-infra-ops-library";

type Action = "plan" | "status" | "init" | "apply" | "backup-now" | "restore-smoke";
interface Options { action: Action; configPath: string; targetId: string | null; confirm: boolean; wait: boolean; output: "text" | "json" }
export interface PikaoaAttachmentsBackupSpec {
  targetId: string; route: string; namespace: string; enabled: boolean; nonBlocking: true; image: string; schedule: string;
  source: { claimName: string; mountPath: string; readOnly: true; excludes: string[] };
  destination:
    | { type: "sftp"; route: string; host: string; port: number; username: string; directory: string }
    | { type: "local-directory"; route: string; path: string; mountPath: string };
  secret: { configRef: string; declarationId: string; targetId: string; secretName: string; keys: { password: string; privateKey?: string; knownHosts?: string } };
  retention: { keepDaily: number; keepWeekly: number; keepMonthly: number };
  maintenance: { checkSchedule: string; forgetPruneSchedule: string };
  restoreSmoke: { jobPrefix: string; emptyDirSizeLimit: string; ttlSecondsAfterFinished: number };
  jobs: { initJobPrefix: string; backupCronJobName: string; checkCronJobName: string; forgetPruneCronJobName: string; serviceAccountName: string; fieldManager: string };
}
interface Prerequisites {
  namespace: boolean;
  sourcePvc: boolean;
  secret: boolean;
  repositoryDirectory: PikaoaAttachmentsRepositoryPrerequisite;
  ready: boolean;
  waiting: string[];
}
export interface PikaoaAttachmentsRepositoryPrerequisite {
  exists: boolean;
  disposition: "ready" | "will-create" | "waiting";
  blocking: boolean;
}

const DEFAULT_CONFIG_PATH = rootPath("config", "pikaoa.yaml");

export function pikaoaAttachmentsBackupHelp(): Record<string, unknown> {
  return {
    command: "pikaoa attachments-backup",
    description: "管理 PikaOA 生产附件的 YAML-first、只读 Restic 备份。",
    usage: [
      "bun scripts/cli.ts pikaoa attachments-backup plan [--target id] [--output json]",
      "bun scripts/cli.ts pikaoa attachments-backup status [--target id] [--output json]",
      "bun scripts/cli.ts pikaoa attachments-backup init [--target id] --confirm",
      "bun scripts/cli.ts pikaoa attachments-backup apply [--target id] [--confirm] [--wait]",
      "bun scripts/cli.ts pikaoa attachments-backup backup-now [--target id] --confirm",
      "bun scripts/cli.ts pikaoa attachments-backup restore-smoke [--target id] --confirm",
    ],
    source: "config/pikaoa.yaml#delivery.targets.*.attachmentsBackup",
    guarantees: [
      "源 PVC 始终只读挂载并排除 .tmp。",
      "日常备份、check 与 forget/prune 使用独立 CronJob。",
      "restore-smoke 只恢复到临时 emptyDir，绝不挂载生产 PVC。",
      "Secret 只经 YAML sourceRef/targetKey 分发，输出不打印值。",
      "repository 可由 YAML 选择本机 hostPath 目录或兼容 SFTP 目标。",
      "init 仅在 restic cat config 精确报告 repository-not-found 时创建仓库；已初始化时结构化 no-op，认证或网络错误保持失败。",
      "前置条件未就绪时返回 prerequisite-waiting，且不阻塞业务、PaC 或 Argo。",
    ],
  };
}

export async function runPikaoaAttachmentsBackupCommand(config: UniDeskConfig, args: string[]): Promise<RenderedCliResult> {
  if (args.length === 0 || args.includes("--help") || args.includes("-h")) return renderMachine("pikaoa attachments-backup", pikaoaAttachmentsBackupHelp(), "json");
  const options = parseOptions(args);
  const spec = readPikaoaAttachmentsBackupSpec(options.configPath, options.targetId);
  let payload: Record<string, unknown>;
  if (options.action === "plan") payload = planPayload(spec);
  else if (options.action === "status") payload = await statusPayload(config, spec);
  else if (options.action === "init") payload = await initPayload(config, spec, options);
  else if (options.action === "apply") payload = await applyPayload(config, spec, options);
  else if (options.action === "backup-now") payload = await backupNowPayload(config, spec, options);
  else payload = await restoreSmokePayload(config, spec, options);
  return render(options, payload);
}

export function readPikaoaAttachmentsBackupSpec(configPath = DEFAULT_CONFIG_PATH, targetId: string | null = null): PikaoaAttachmentsBackupSpec {
  const root = readYamlRecord(configPath, "pikaoa-platform-delivery");
  const delivery = object(root.delivery, "delivery");
  const targets = object(delivery.targets, "delivery.targets");
  const selectedId = targetId ?? text(object(root.defaults, "defaults").targetId, "defaults.targetId");
  const target = object(targets[selectedId], `delivery.targets.${selectedId}`);
  const backup = object(target.attachmentsBackup, `delivery.targets.${selectedId}.attachmentsBackup`);
  const source = object(backup.source, "attachmentsBackup.source");
  const destination = object(backup.destination, "attachmentsBackup.destination");
  const secret = object(backup.secret, "attachmentsBackup.secret");
  const keys = object(secret.keys, "attachmentsBackup.secret.keys");
  const retention = object(backup.retention, "attachmentsBackup.retention");
  const maintenance = object(backup.maintenance, "attachmentsBackup.maintenance");
  const smoke = object(backup.restoreSmoke, "attachmentsBackup.restoreSmoke");
  const jobs = object(backup.jobs, "attachmentsBackup.jobs");
  const destinationType = backupDestinationType(destination.type);
  const parsedDestination: PikaoaAttachmentsBackupSpec["destination"] = destinationType === "local-directory"
    ? { type: destinationType, route: text(destination.route, "destination.route"), path: path(destination.path, "destination.path"), mountPath: path(destination.mountPath, "destination.mountPath") }
    : { type: destinationType, route: text(destination.route, "destination.route"), host: text(destination.host, "destination.host"), port: integer(destination.port, "destination.port"), username: name(destination.username, "destination.username"), directory: path(destination.directory, "destination.directory") };
  const parsedKeys: PikaoaAttachmentsBackupSpec["secret"]["keys"] = { password: env(keys.password, "keys.password") };
  if (destinationType === "sftp") {
    parsedKeys.privateKey = env(keys.privateKey, "keys.privateKey");
    parsedKeys.knownHosts = env(keys.knownHosts, "keys.knownHosts");
  }
  const spec: PikaoaAttachmentsBackupSpec = {
    targetId: selectedId,
    route: text(target.route, "target.route"),
    namespace: dns(source.namespace, "source.namespace"),
    enabled: bool(backup.enabled, "enabled"),
    nonBlocking: yes(backup.nonBlocking, "nonBlocking"),
    image: text(backup.image, "image"),
    schedule: cron(backup.schedule, "schedule"),
    source: { claimName: dns(source.claimName, "source.claimName"), mountPath: path(source.mountPath, "source.mountPath"), readOnly: yes(source.readOnly, "source.readOnly"), excludes: texts(source.excludes, "source.excludes") },
    destination: parsedDestination,
    secret: { configRef: text(secret.configRef, "secret.configRef"), declarationId: name(secret.declarationId, "secret.declarationId"), targetId: name(secret.targetId, "secret.targetId"), secretName: dns(secret.secretName, "secret.secretName"), keys: parsedKeys },
    retention: { keepDaily: integer(retention.keepDaily, "retention.keepDaily"), keepWeekly: integer(retention.keepWeekly, "retention.keepWeekly"), keepMonthly: integer(retention.keepMonthly, "retention.keepMonthly") },
    maintenance: { checkSchedule: cron(maintenance.checkSchedule, "maintenance.checkSchedule"), forgetPruneSchedule: cron(maintenance.forgetPruneSchedule, "maintenance.forgetPruneSchedule") },
    restoreSmoke: { jobPrefix: dns(smoke.jobPrefix, "restoreSmoke.jobPrefix"), emptyDirSizeLimit: text(smoke.emptyDirSizeLimit, "restoreSmoke.emptyDirSizeLimit"), ttlSecondsAfterFinished: integer(smoke.ttlSecondsAfterFinished, "restoreSmoke.ttlSecondsAfterFinished") },
    jobs: { initJobPrefix: dns(jobs.initJobPrefix, "jobs.initJobPrefix"), backupCronJobName: dns(jobs.backupCronJobName, "jobs.backupCronJobName"), checkCronJobName: dns(jobs.checkCronJobName, "jobs.checkCronJobName"), forgetPruneCronJobName: dns(jobs.forgetPruneCronJobName, "jobs.forgetPruneCronJobName"), serviceAccountName: dns(jobs.serviceAccountName, "jobs.serviceAccountName"), fieldManager: name(jobs.fieldManager, "jobs.fieldManager") },
  };
  if (!spec.source.excludes.includes(".tmp")) throw new Error("attachmentsBackup.source.excludes must include .tmp");
  return spec;
}

export function renderPikaoaAttachmentsBackupManifest(spec: PikaoaAttachmentsBackupSpec): string {
  return [serviceAccount(spec), cronJob(spec, "backup"), cronJob(spec, "check"), cronJob(spec, "forget-prune")].join("---\n");
}

export function renderPikaoaAttachmentsRestoreSmokeJob(spec: PikaoaAttachmentsBackupSpec, jobName: string): string {
  return `apiVersion: batch/v1
kind: Job
metadata:
  name: ${jobName}
  namespace: ${spec.namespace}
  labels:
    app.kubernetes.io/name: pikaoa-attachments-backup
    app.kubernetes.io/component: restore-smoke
spec:
  ttlSecondsAfterFinished: ${spec.restoreSmoke.ttlSecondsAfterFinished}
  template:
    metadata:
      labels:
        app.kubernetes.io/name: pikaoa-attachments-backup
        app.kubernetes.io/component: restore-smoke
    spec:
      restartPolicy: Never
      serviceAccountName: ${spec.jobs.serviceAccountName}
      containers:
        - name: restore-smoke
          image: ${spec.image}
          command: ["/bin/sh", "-ceu"]
          args:
            - |
${indentBlock(resticPrelude(spec), 14)}
              ${resticInvocation(spec, "restore latest --target /restore")}
              objects="$(find /restore -mindepth 1 -type f | wc -l | tr -d ' ')"
              printf 'event=pikaoa.attachments.restore-smoke objects=%s target=temporary-empty-dir\\n' "$objects"
          volumeMounts:
            - name: backup-secret
              mountPath: /backup-secret
              readOnly: true
            - name: restore-target
              mountPath: /restore
${repositoryVolumeMount(spec, 12)}
      volumes:
        - name: backup-secret
          secret:
            secretName: ${spec.secret.secretName}
            defaultMode: 0400
        - name: restore-target
          emptyDir:
            sizeLimit: ${spec.restoreSmoke.emptyDirSizeLimit}
${repositoryVolume(spec, 8)}
`;
}

export function renderPikaoaAttachmentsInitJob(spec: PikaoaAttachmentsBackupSpec, jobName: string): string {
  return `apiVersion: batch/v1
kind: Job
metadata:
  name: ${jobName}
  namespace: ${spec.namespace}
  labels:
    app.kubernetes.io/name: pikaoa-attachments-backup
    app.kubernetes.io/component: repository-init
spec:
  ttlSecondsAfterFinished: ${spec.restoreSmoke.ttlSecondsAfterFinished}
  template:
    metadata:
      labels:
        app.kubernetes.io/name: pikaoa-attachments-backup
        app.kubernetes.io/component: repository-init
    spec:
      restartPolicy: Never
      serviceAccountName: ${spec.jobs.serviceAccountName}
      containers:
        - name: repository-init
          image: ${spec.image}
          command: ["/bin/sh", "-ceu"]
          args:
            - |
${indentBlock(resticPrelude(spec), 14)}
              set +e
              cat_output="$(${resticInvocation(spec, "cat config")} 2>&1)"
              cat_status="$?"
              set -e
              if [ "$cat_status" -eq 0 ]; then
                printf 'event=pikaoa.attachments.repository-init result=already-initialized mutation=false\\n'
                exit 0
              fi
              if [ "$cat_status" -ne 10 ]; then
                printf 'event=pikaoa.attachments.repository-init result=probe-failed mutation=false catConfigExitCode=%s\\n' "$cat_status" >&2
                printf '%s\\n' "$cat_output" >&2
                exit "$cat_status"
              fi
              ${resticInvocation(spec, "init")}
              printf 'event=pikaoa.attachments.repository-init result=initialized mutation=true\\n'
          volumeMounts:
            - name: backup-secret
              mountPath: /backup-secret
              readOnly: true
${repositoryVolumeMount(spec, 12)}
      volumes:
        - name: backup-secret
          secret:
            secretName: ${spec.secret.secretName}
            defaultMode: 0400
${repositoryVolume(spec, 8)}
`;
}

function planPayload(spec: PikaoaAttachmentsBackupSpec): Record<string, unknown> {
  const manifest = renderPikaoaAttachmentsBackupManifest(spec);
  return {
    ok: true, action: "pikaoa-attachments-backup-plan", mode: "local-render", mutation: false,
    target: targetSummary(spec), schedule: { backup: spec.schedule, check: spec.maintenance.checkSchedule, forgetPrune: spec.maintenance.forgetPruneSchedule },
    retention: spec.retention, source: spec.source,
    destination: { ...spec.destination, repositoryCredentialsPrinted: false }, secret: secretSummary(spec), maintenance: spec.maintenance,
    manifests: { objects: 4, fingerprint: sha256Fingerprint(manifest), yaml: manifest },
    policy: { nonBlocking: true, sourcePvcReadOnly: true, restoreTarget: "temporary-emptyDir", valuesPrinted: false },
    requirements: ["production namespace", "production attachments PVC", "YAML-first Secret sync", "YAML-declared repository directory"], next: next(spec),
  };
}

async function statusPayload(config: UniDeskConfig, spec: PikaoaAttachmentsBackupSpec): Promise<Record<string, unknown>> {
  const prerequisites = await inspectPrerequisites(config, spec);
  const cronJobs: Record<string, boolean> = {};
  for (const cronJobName of [spec.jobs.backupCronJobName, spec.jobs.checkCronJobName, spec.jobs.forgetPruneCronJobName]) {
    cronJobs[cronJobName] = await remoteExists(config, spec.route, ["kubectl", "-n", spec.namespace, "get", "cronjob", cronJobName, "-o", "name"]);
  }
  const initJobs = await capture(config, spec.route, ["kubectl", "-n", spec.namespace, "get", "jobs", "-l", "app.kubernetes.io/component=repository-init", "--sort-by=.metadata.creationTimestamp", "-o", "custom-columns=NAME:.metadata.name,SUCCEEDED:.status.succeeded,FAILED:.status.failed", "--no-headers"], "");
  const recentJobs = await capture(config, spec.route, ["kubectl", "-n", spec.namespace, "get", "jobs", "-l", "app.kubernetes.io/name=pikaoa-attachments-backup", "--sort-by=.metadata.creationTimestamp", "-o", "custom-columns=NAME:.metadata.name,SUCCEEDED:.status.succeeded,FAILED:.status.failed", "--no-headers"], "");
  return { ok: true, action: "pikaoa-attachments-backup-status", mode: prerequisites.ready ? "ready" : "prerequisite-waiting", mutation: false, businessBlocking: false, target: targetSummary(spec), prerequisites, resources: { cronJobs, initJobs: compactCapture(initJobs, { full: true }), recentJobs: compactCapture(recentJobs, { full: true }) }, secret: secretSummary(spec), next: next(spec) };
}

async function initPayload(config: UniDeskConfig, spec: PikaoaAttachmentsBackupSpec, options: Options): Promise<Record<string, unknown>> {
  requireConfirm(options, "init");
  const prerequisites = await inspectPrerequisites(config, spec);
  if (!prerequisites.ready) return { ...waiting("pikaoa-attachments-repository-init", prerequisites, spec), productionPvcMounted: false };
  const jobName = `${spec.jobs.initJobPrefix}-${Date.now().toString(36)}`.slice(0, 63);
  const manifest = renderPikaoaAttachmentsInitJob(spec, jobName);
  const result = await capture(config, spec.route, ["sh"], `kubectl apply --server-side --field-manager=${shQuote(spec.jobs.fieldManager)} -f - <<'YAML'\n${manifest}YAML\n`);
  return { ok: result.exitCode === 0, action: "pikaoa-attachments-repository-init", mode: "submitted", mutation: result.exitCode === 0, businessBlocking: false, target: targetSummary(spec), productionPvcMounted: false, jobName, result: compactCapture(result), statusCommand: `bun scripts/cli.ts pikaoa attachments-backup status --target ${spec.targetId}` };
}

async function applyPayload(config: UniDeskConfig, spec: PikaoaAttachmentsBackupSpec, options: Options): Promise<Record<string, unknown>> {
  if (!options.confirm) return { ...planPayload(spec), action: "pikaoa-attachments-backup-apply", mode: "dry-run" };
  const prerequisites = await inspectPrerequisites(config, spec);
  if (!prerequisites.ready) return waiting("pikaoa-attachments-backup-apply", prerequisites, spec);
  if (!options.wait) {
    const job = startJob("pikaoa_attachments_backup_apply", ["bun", "scripts/cli.ts", "pikaoa", "attachments-backup", "apply", "--config", options.configPath, "--target", spec.targetId, "--confirm", "--wait"], "Apply YAML-declared PikaOA attachments backup CronJobs after prerequisites are ready");
    return { ok: true, action: "pikaoa-attachments-backup-apply", mode: "async-job", mutation: true, target: targetSummary(spec), job, statusCommand: `bun scripts/cli.ts job status ${job.id} --tail-bytes 12000` };
  }
  const manifest = renderPikaoaAttachmentsBackupManifest(spec);
  const result = await capture(config, spec.route, ["sh"], `kubectl apply --server-side --field-manager=${shQuote(spec.jobs.fieldManager)} -f - <<'YAML'\n${manifest}YAML\n`);
  return { ok: result.exitCode === 0, action: "pikaoa-attachments-backup-apply", mode: "confirmed", mutation: true, target: targetSummary(spec), result: compactCapture(result), next: next(spec) };
}

async function backupNowPayload(config: UniDeskConfig, spec: PikaoaAttachmentsBackupSpec, options: Options): Promise<Record<string, unknown>> {
  requireConfirm(options, "backup-now");
  const prerequisites = await inspectPrerequisites(config, spec);
  if (!prerequisites.ready) return waiting("pikaoa-attachments-backup-now", prerequisites, spec);
  const jobName = `${spec.jobs.backupCronJobName}-manual-${Date.now().toString(36)}`.slice(0, 63);
  const result = await capture(config, spec.route, ["kubectl", "-n", spec.namespace, "create", "job", `--from=cronjob/${spec.jobs.backupCronJobName}`, jobName, "-o", "name"], "");
  return { ok: result.exitCode === 0, action: "pikaoa-attachments-backup-now", mode: "submitted", mutation: true, target: targetSummary(spec), jobName, result: compactCapture(result), statusCommand: `bun scripts/cli.ts pikaoa attachments-backup status --target ${spec.targetId}` };
}

async function restoreSmokePayload(config: UniDeskConfig, spec: PikaoaAttachmentsBackupSpec, options: Options): Promise<Record<string, unknown>> {
  requireConfirm(options, "restore-smoke");
  const prerequisites = await inspectPrerequisites(config, spec);
  if (!prerequisites.ready) return waiting("pikaoa-attachments-restore-smoke", prerequisites, spec);
  const jobName = `${spec.restoreSmoke.jobPrefix}-${Date.now().toString(36)}`.slice(0, 63);
  const manifest = renderPikaoaAttachmentsRestoreSmokeJob(spec, jobName);
  const result = await capture(config, spec.route, ["sh"], `kubectl apply --server-side --field-manager=${shQuote(spec.jobs.fieldManager)} -f - <<'YAML'\n${manifest}YAML\n`);
  return { ok: result.exitCode === 0, action: "pikaoa-attachments-restore-smoke", mode: "submitted", mutation: true, target: targetSummary(spec), restoreTarget: "temporary-emptyDir", productionPvcMounted: false, jobName, result: compactCapture(result), statusCommand: `bun scripts/cli.ts pikaoa attachments-backup status --target ${spec.targetId}` };
}

async function inspectPrerequisites(config: UniDeskConfig, spec: PikaoaAttachmentsBackupSpec): Promise<Prerequisites> {
  const namespace = await remoteExists(config, spec.route, ["kubectl", "get", "namespace", spec.namespace, "-o", "name"]);
  const sourcePvc = namespace && await remoteExists(config, spec.route, ["kubectl", "-n", spec.namespace, "get", "pvc", spec.source.claimName, "-o", "name"]);
  const secret = namespace && await remoteExists(config, spec.route, ["kubectl", "-n", spec.namespace, "get", "secret", spec.secret.secretName, "-o", "name"]);
  const destinationDirectory = await remoteExists(config, spec.destination.route, ["sh"], `test -d ${shQuote(destinationPath(spec))}\n`);
  const repositoryDirectory = projectPikaoaAttachmentsRepositoryPrerequisite(spec.destination.type, destinationDirectory);
  const waiting: string[] = [];
  if (!namespace) waiting.push("production-namespace");
  if (!sourcePvc) waiting.push("production-attachments-pvc");
  if (!secret) waiting.push("yaml-first-secret-sync");
  if (repositoryDirectory.blocking) waiting.push("repository-directory");
  return { namespace, sourcePvc, secret, repositoryDirectory, ready: waiting.length === 0, waiting };
}

export function projectPikaoaAttachmentsRepositoryPrerequisite(type: PikaoaAttachmentsBackupSpec["destination"]["type"], exists: boolean): PikaoaAttachmentsRepositoryPrerequisite {
  if (exists) return { exists: true, disposition: "ready", blocking: false };
  if (type === "local-directory") return { exists: false, disposition: "will-create", blocking: false };
  return { exists: false, disposition: "waiting", blocking: true };
}

async function remoteExists(config: UniDeskConfig, route: string, args: string[], stdin = ""): Promise<boolean> {
  return (await capture(config, route, args, stdin)).exitCode === 0;
}

function serviceAccount(spec: PikaoaAttachmentsBackupSpec): string {
  return `apiVersion: v1\nkind: ServiceAccount\nmetadata:\n  name: ${spec.jobs.serviceAccountName}\n  namespace: ${spec.namespace}\n  labels:\n    app.kubernetes.io/name: pikaoa-attachments-backup\nautomountServiceAccountToken: false\n`;
}

function cronJob(spec: PikaoaAttachmentsBackupSpec, operation: "backup" | "check" | "forget-prune"): string {
  const nameValue = operation === "backup" ? spec.jobs.backupCronJobName : operation === "check" ? spec.jobs.checkCronJobName : spec.jobs.forgetPruneCronJobName;
  const schedule = operation === "backup" ? spec.schedule : operation === "check" ? spec.maintenance.checkSchedule : spec.maintenance.forgetPruneSchedule;
  const attachmentMount = operation === "backup" ? `                - name: attachments\n                  mountPath: ${spec.source.mountPath}\n                  readOnly: true\n` : "";
  const attachmentVolume = operation === "backup" ? `            - name: attachments\n              persistentVolumeClaim:\n                claimName: ${spec.source.claimName}\n                readOnly: true\n` : "";
  return `apiVersion: batch/v1
kind: CronJob
metadata:
  name: ${nameValue}
  namespace: ${spec.namespace}
  labels:
    app.kubernetes.io/name: pikaoa-attachments-backup
    app.kubernetes.io/component: ${operation}
spec:
  schedule: ${JSON.stringify(schedule)}
  concurrencyPolicy: Forbid
  successfulJobsHistoryLimit: 2
  failedJobsHistoryLimit: 4
  jobTemplate:
    spec:
      ttlSecondsAfterFinished: 86400
      template:
        metadata:
          labels:
            app.kubernetes.io/name: pikaoa-attachments-backup
            app.kubernetes.io/component: ${operation}
        spec:
          restartPolicy: Never
          serviceAccountName: ${spec.jobs.serviceAccountName}
          containers:
            - name: restic-${operation}
              image: ${spec.image}
              command: ["/bin/sh", "-ceu"]
              args:
                - |
${indentBlock(resticPrelude(spec), 18)}
                  ${resticCommand(spec, operation)}
              volumeMounts:
                - name: backup-secret
                  mountPath: /backup-secret
                  readOnly: true
${repositoryVolumeMount(spec, 16)}${attachmentMount}          volumes:
            - name: backup-secret
              secret:
                secretName: ${spec.secret.secretName}
                defaultMode: 0400
${repositoryVolume(spec, 12)}${attachmentVolume}`;
}

function resticPrelude(spec: PikaoaAttachmentsBackupSpec): string {
  if (spec.destination.type === "local-directory") {
    return `export RESTIC_REPOSITORY=${shQuote(spec.destination.mountPath)}\nexport RESTIC_PASSWORD_FILE=/backup-secret/${spec.secret.keys.password}`;
  }
  const repository = `sftp:${spec.destination.username}@${spec.destination.host}:${spec.destination.directory}`;
  const sshCommand = `ssh -p ${spec.destination.port} -i /backup-secret/${spec.secret.keys.privateKey!} -o UserKnownHostsFile=/backup-secret/${spec.secret.keys.knownHosts!} -o StrictHostKeyChecking=yes`;
  return `export RESTIC_REPOSITORY=${shQuote(repository)}\nexport RESTIC_PASSWORD_FILE=/backup-secret/${spec.secret.keys.password}\nexport RESTIC_SFTP_COMMAND=${shQuote(sshCommand)}`;
}

function resticInvocation(spec: PikaoaAttachmentsBackupSpec, args: string): string {
  return spec.destination.type === "sftp" ? `restic -o sftp.command="$RESTIC_SFTP_COMMAND" ${args}` : `restic ${args}`;
}

function destinationPath(spec: PikaoaAttachmentsBackupSpec): string {
  return spec.destination.type === "sftp" ? spec.destination.directory : spec.destination.path;
}

function repositoryVolumeMount(spec: PikaoaAttachmentsBackupSpec, spaces: number): string {
  if (spec.destination.type !== "local-directory") return "";
  return `${" ".repeat(spaces)}- name: repository\n${" ".repeat(spaces + 2)}mountPath: ${spec.destination.mountPath}\n`;
}

function repositoryVolume(spec: PikaoaAttachmentsBackupSpec, spaces: number): string {
  if (spec.destination.type !== "local-directory") return "";
  return `${" ".repeat(spaces)}- name: repository\n${" ".repeat(spaces + 2)}hostPath:\n${" ".repeat(spaces + 4)}path: ${spec.destination.path}\n${" ".repeat(spaces + 4)}type: DirectoryOrCreate\n`;
}

function indentBlock(value: string, spaces: number): string {
  const indentation = " ".repeat(spaces);
  return value.split("\n").map((line) => `${indentation}${line}`).join("\n");
}

function resticCommand(spec: PikaoaAttachmentsBackupSpec, operation: "backup" | "check" | "forget-prune"): string {
  if (operation === "check") return resticInvocation(spec, "check --read-data-subset=5%");
  if (operation === "forget-prune") return resticInvocation(spec, `forget --keep-daily ${spec.retention.keepDaily} --keep-weekly ${spec.retention.keepWeekly} --keep-monthly ${spec.retention.keepMonthly} --prune`);
  const excludes = spec.source.excludes.flatMap((value) => [`--exclude=${value}`, `--exclude=${spec.source.mountPath}/${value}`]).map(shQuote).join(" ");
  return resticInvocation(spec, `backup ${shQuote(spec.source.mountPath)} ${excludes} --tag pikaoa-attachments`);
}

function waiting(action: string, prerequisites: Prerequisites, spec: PikaoaAttachmentsBackupSpec): Record<string, unknown> {
  return { ok: true, action, mode: "prerequisite-waiting", mutation: false, businessBlocking: false, target: targetSummary(spec), prerequisites, next: next(spec) };
}

function targetSummary(spec: PikaoaAttachmentsBackupSpec): Record<string, unknown> {
  return { id: spec.targetId, route: spec.route, namespace: spec.namespace, enabled: spec.enabled, nonBlocking: spec.nonBlocking, repositoryType: spec.destination.type };
}

function secretSummary(spec: PikaoaAttachmentsBackupSpec): Record<string, unknown> {
  return { configRef: spec.secret.configRef, declarationId: spec.secret.declarationId, secretName: spec.secret.secretName, targetKeys: Object.values(spec.secret.keys), presence: "status-only", fingerprint: sha256Fingerprint(JSON.stringify(spec.secret)), valuesPrinted: false };
}

function next(spec: PikaoaAttachmentsBackupSpec): Record<string, string> {
  return {
    secrets: `bun scripts/cli.ts secrets sync --config ${spec.secret.configRef} --target ${spec.secret.targetId} --confirm`,
    status: `bun scripts/cli.ts pikaoa attachments-backup status --target ${spec.targetId}`,
    init: `bun scripts/cli.ts pikaoa attachments-backup init --target ${spec.targetId} --confirm`,
    apply: `bun scripts/cli.ts pikaoa attachments-backup apply --target ${spec.targetId} --confirm`,
    backupNow: `bun scripts/cli.ts pikaoa attachments-backup backup-now --target ${spec.targetId} --confirm`,
    restoreSmoke: `bun scripts/cli.ts pikaoa attachments-backup restore-smoke --target ${spec.targetId} --confirm`,
    plan: `bun scripts/cli.ts pikaoa attachments-backup plan --target ${spec.targetId}`,
  };
}

function parseOptions(args: string[]): Options {
  const action = args[0];
  if (!isAction(action)) throw inputError("action 必须是 plan、status、init、apply、backup-now 或 restore-smoke", action ?? "<missing>");
  let configPath = DEFAULT_CONFIG_PATH;
  let targetId: string | null = null;
  let confirm = false;
  let wait = false;
  let output: "text" | "json" = "text";
  for (let index = 1; index < args.length; index += 1) {
    const arg = args[index]!;
    if (arg === "--config" || arg === "--target" || arg === "--output" || arg === "-o") {
      const value = args[index + 1];
      if (value === undefined || value.startsWith("--")) throw inputError(`${arg} 需要参数`, arg);
      if (arg === "--config") configPath = rootPath(value);
      else if (arg === "--target") targetId = name(value, arg);
      else if (value === "json" || value === "text") output = value;
      else throw inputError(`${arg} 只支持 text 或 json`, value);
      index += 1;
    } else if (arg === "--confirm") confirm = true;
    else if (arg === "--wait") wait = true;
    else if (arg === "--json") output = "json";
    else throw inputError(`不支持的参数：${arg}`, arg);
  }
  return { action, configPath, targetId, confirm, wait, output };
}

function render(options: Options, payload: Record<string, unknown>): RenderedCliResult {
  if (options.output === "json") return renderMachine(`pikaoa attachments-backup ${options.action}`, payload, "json", payload.ok !== false);
  const target = payload.target as Record<string, unknown> | undefined;
  const prerequisites = payload.prerequisites as Prerequisites | undefined;
  const lines = [`PIKAOA ATTACHMENTS BACKUP ${options.action.toUpperCase()} (${String(payload.mode ?? "ok")})`, "", `target=${target?.id ?? "-"} route=${target?.route ?? "-"} namespace=${target?.namespace ?? "-"}`, `mutation=${payload.mutation === true} businessBlocking=${payload.businessBlocking === true} valuesPrinted=false`];
  if (prerequisites !== undefined) lines.push(`prerequisites.ready=${prerequisites.ready} waiting=${prerequisites.waiting.join(",") || "none"}`);
  const commands = payload.next as Record<string, string> | undefined;
  if (commands !== undefined) lines.push("", "NEXT", ...Object.entries(commands).map(([key, value]) => `${key}: ${value}`));
  return { ok: payload.ok !== false, command: `pikaoa attachments-backup ${options.action}`, renderedText: `${lines.join("\n")}\n`, contentType: "text/plain", projection: payload };
}

function requireConfirm(options: Options, action: string): void {
  if (!options.confirm) throw new CliInputError(`${action} 必须显式使用 --confirm`, { code: "confirmation-required", argument: action, usage: `bun scripts/cli.ts pikaoa attachments-backup ${action} --confirm` });
}
function isAction(value: string | undefined): value is Action { return value === "plan" || value === "status" || value === "init" || value === "apply" || value === "backup-now" || value === "restore-smoke" }
function inputError(message: string, argument: string): CliInputError { return new CliInputError(message, { code: "invalid-pikaoa-attachments-backup-input", argument, usage: "bun scripts/cli.ts pikaoa attachments-backup plan|status|init|apply|backup-now|restore-smoke [options]" }) }
function object(value: unknown, label: string): Record<string, unknown> { if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${label} must be an object`); return value as Record<string, unknown> }
function text(value: unknown, label: string): string { if (typeof value !== "string" || value.length === 0) throw new Error(`${label} must be a non-empty string`); return value }
function bool(value: unknown, label: string): boolean { if (typeof value !== "boolean") throw new Error(`${label} must be boolean`); return value }
function yes(value: unknown, label: string): true { if (value !== true) throw new Error(`${label} must be true`); return true }
function integer(value: unknown, label: string): number { if (!Number.isInteger(value) || Number(value) <= 0) throw new Error(`${label} must be a positive integer`); return Number(value) }
function texts(value: unknown, label: string): string[] { if (!Array.isArray(value) || value.length === 0 || value.some((item) => typeof item !== "string" || item.length === 0)) throw new Error(`${label} must be a non-empty string array`); return value as string[] }
function path(value: unknown, label: string): string { const parsed = text(value, label); if (!parsed.startsWith("/") || parsed.includes("..")) throw new Error(`${label} must be an absolute path without ..`); return parsed }
function name(value: unknown, label: string): string { const parsed = text(value, label); if (!/^[A-Za-z0-9._-]+$/u.test(parsed)) throw new Error(`${label} has unsupported characters`); return parsed }
function dns(value: unknown, label: string): string { const parsed = text(value, label); if (!/^[a-z0-9]([-a-z0-9]*[a-z0-9])?$/u.test(parsed) || parsed.length > 63) throw new Error(`${label} must be a DNS label`); return parsed }
function env(value: unknown, label: string): string { const parsed = text(value, label); if (!/^[A-Z_][A-Z0-9_]*$/u.test(parsed)) throw new Error(`${label} must be an environment key`); return parsed }
function cron(value: unknown, label: string): string { const parsed = text(value, label); if (parsed.trim().split(/\s+/u).length !== 5) throw new Error(`${label} must use five-field cron syntax`); return parsed }
function backupDestinationType(value: unknown): "sftp" | "local-directory" { if (value !== "sftp" && value !== "local-directory") throw new Error("destination.type must be sftp or local-directory"); return value }
