// SPEC: PJ2026-01060307 控制面模块化 draft-2026-06-25-p0. git-mirror module for scripts/src/agentrun.ts.

// Moved mechanically from scripts/src/agentrun.ts:5384-5846 for #903.

// SPEC: PJ2026-01020108 cancel lifecycle + PJ2026-01020205 AipodSpec binding + PJ2026-01020302 session policy + PJ2026-01020305 cancel control + PJ2026-01060305/06 YAML execution policy and bounded output draft-2026-06-25-p0.
// Exposes AgentRun lane-scoped policy, AipodSpec SecretRef binding, cancel lifecycle, and bounded default output in the UniDesk CLI.
import { chmodSync, copyFileSync, existsSync, readFileSync, statSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { spawnSync } from "node:child_process";
import { rootPath, type UniDeskConfig } from "../config";
import type { RenderedCliResult } from "../output";
import { applyLocalCaddyManagedSite } from "../pk01-caddy";
import { runSshCommandCapture, type SshCaptureResult } from "../ssh";
import { runRemoteSshCommandCapture } from "../remote";
import { startJob } from "../jobs";
import {
  AGENTRUN_CONFIG_PATH,
  agentRunLaneSummary,
  agentRunPipelineRunName,
  agentRunProviderCredentialRefs,
  resolveAgentRunLaneTarget,
  type AgentRunCancelLifecycleSpec,
  type AgentRunLaneSpec,
} from "../agentrun-lanes";
import {
  agentRunImageArtifact,
  placeholderAgentRunImage,
  renderedFilesDigest,
  renderedObjectsDigest,
  renderAgentRunControlPlaneManifests,
  renderAgentRunGitopsFiles,
  type AgentRunArtifactService,
} from "../agentrun-manifests";
import { sha256Fingerprint } from "../platform-infra-ops-library";

import type { GitMirrorStatusOptions } from "./options";
import { readGitMirrorStatus } from "./rest-bridge";
import { compactCapture, shQuote } from "./utils";

export function cleanupRunnersFinalizeNodeScript(): string {
  return String.raw`
const fs = require("node:fs");
const path = require("node:path");
const tmp = process.env.TMP_DIR;
const deleteExit = Number(process.env.DELETE_EXIT || 0);
const plan = JSON.parse(fs.readFileSync(path.join(tmp, "plan.json"), "utf8"));
const jobsAfter = JSON.parse(fs.readFileSync(path.join(tmp, "jobs-after.json"), "utf8"));
const podsAfter = JSON.parse(fs.readFileSync(path.join(tmp, "pods-after.json"), "utf8"));
const matchLabels = plan.criteria?.selectors?.matchLabels || {};
const jobNamePrefixes = plan.criteria?.selectors?.jobNamePrefixes || [];

function labelsOf(item) {
  return item?.metadata?.labels && typeof item.metadata.labels === "object" ? item.metadata.labels : {};
}

function matchesLabels(labels) {
  return Object.entries(matchLabels).every(([key, value]) => labels?.[key] === value);
}

function matchesPrefix(name) {
  return jobNamePrefixes.length === 0 || jobNamePrefixes.some((prefix) => name === prefix || name.startsWith(prefix + "-"));
}

function isTerminalPod(pod) {
  const phase = pod?.status?.phase || "";
  return phase === "Succeeded" || phase === "Failed";
}

function jobNameForPod(pod) {
  const labels = labelsOf(pod);
  if (typeof labels["job-name"] === "string") return labels["job-name"];
  const owner = (Array.isArray(pod?.metadata?.ownerReferences) ? pod.metadata.ownerReferences : []).find((entry) => entry?.kind === "Job" && typeof entry?.name === "string");
  return owner?.name || null;
}

function tail(name) {
  try {
    const text = fs.readFileSync(path.join(tmp, name), "utf8");
    return text.length > 3000 ? text.slice(-3000) : text;
  } catch {
    return "";
  }
}

const selected = new Set(Array.isArray(plan.selectedRunnerJobs) ? plan.selectedRunnerJobs : []);
const remainingJobs = (Array.isArray(jobsAfter.items) ? jobsAfter.items : [])
  .map((job) => ({ name: job?.metadata?.name || "", labels: labelsOf(job) }))
  .filter((item) => item.name && matchesLabels(item.labels) && matchesPrefix(item.name));
const remainingJobNames = new Set(remainingJobs.map((item) => item.name));
const matchedPods = (Array.isArray(podsAfter.items) ? podsAfter.items : []).filter((pod) => {
  const jobName = jobNameForPod(pod);
  return jobName !== null && remainingJobNames.has(jobName);
});
const remainingSelectedRunnerJobs = Array.from(selected).filter((name) => remainingJobNames.has(name));
const deletedRunnerJobs = Array.from(selected).filter((name) => !remainingJobNames.has(name));
console.log(JSON.stringify({
  ...plan,
  ok: deleteExit === 0,
  deletion: { exitCode: deleteExit, stdoutTail: tail("delete.out"), stderrTail: tail("delete.err") },
  deletedRunnerJobs,
  deletedRunnerJobCount: deletedRunnerJobs.length,
  remainingSelectedRunnerJobs,
  remainingSelectedRunnerJobCount: remainingSelectedRunnerJobs.length,
  after: {
    runnerJobCount: remainingJobs.length,
    nonTerminalRunnerPodCount: matchedPods.filter((pod) => !isTerminalPod(pod)).length,
    overLimitCount: Math.max(0, remainingJobs.length - Number(plan.criteria?.maxRunners || 0)),
  },
  valuesPrinted: false,
}));
`;
}

export function cleanupRunsPlanNodeScript(): string {
  return String.raw`
const fs = require("node:fs");
const path = require("node:path");
const cp = require("node:child_process");
const tmp = process.env.TMP_DIR;
const namespace = process.env.NAMESPACE;
const pipelineRunPrefix = process.env.PIPELINE_RUN_PREFIX || "";
const minAgeMinutes = Number(process.env.MIN_AGE_MINUTES || 60);
const limit = Number(process.env.LIMIT || 20);
const now = Date.now();

function readJson(name) {
  return JSON.parse(fs.readFileSync(path.join(tmp, name), "utf8"));
}

function conditionOf(item) {
  const conditions = Array.isArray(item?.status?.conditions) ? item.status.conditions : [];
  return conditions.find((entry) => entry.type === "Succeeded") || conditions[0] || {};
}

function ageMinutes(createdAt) {
  const createdMs = Date.parse(createdAt || "");
  return Number.isFinite(createdMs) ? Math.floor((now - createdMs) / 60000) : null;
}

function localPathOf(pv) {
  return pv?.spec?.local?.path || pv?.spec?.hostPath?.path || null;
}

function duBytes(hostPath) {
  if (typeof hostPath !== "string" || !hostPath.startsWith("/var/lib/rancher/k3s/storage/")) return null;
  try {
    const out = cp.execFileSync("du", ["-sB1", hostPath], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
    const bytes = Number(String(out).trim().split(/\s+/)[0]);
    return Number.isFinite(bytes) ? bytes : null;
  } catch {
    return null;
  }
}

function formatBytes(bytes) {
  if (!Number.isFinite(bytes)) return null;
  const units = ["B", "KiB", "MiB", "GiB", "TiB"];
  let value = bytes;
  let unit = 0;
  while (value >= 1024 && unit < units.length - 1) {
    value /= 1024;
    unit += 1;
  }
  return value.toFixed(unit === 0 ? 0 : 1) + units[unit];
}

const pipelineRuns = readJson("pipelineruns.json");
const pvcs = readJson("pvcs.json");
const pvs = readJson("pvs.json");
const pods = readJson("pods.json");
const pvByName = new Map((Array.isArray(pvs.items) ? pvs.items : []).map((item) => [item?.metadata?.name, item]));
const activeClaimPods = new Map();
for (const pod of Array.isArray(pods.items) ? pods.items : []) {
  const phase = pod?.status?.phase || "";
  if (phase === "Succeeded" || phase === "Failed") continue;
  for (const volume of Array.isArray(pod?.spec?.volumes) ? pod.spec.volumes : []) {
    const claimName = volume?.persistentVolumeClaim?.claimName;
    if (!claimName) continue;
    const entry = activeClaimPods.get(claimName) || [];
    entry.push(pod?.metadata?.name || null);
    activeClaimPods.set(claimName, entry.filter(Boolean));
  }
}

const pvcsByOwner = new Map();
for (const pvc of Array.isArray(pvcs.items) ? pvcs.items : []) {
  const owner = (Array.isArray(pvc?.metadata?.ownerReferences) ? pvc.metadata.ownerReferences : []).find((entry) => entry.kind === "PipelineRun");
  if (!owner?.name) continue;
  const entry = pvcsByOwner.get(owner.name) || [];
  entry.push(pvc);
  pvcsByOwner.set(owner.name, entry);
}

const allPipelineRuns = (Array.isArray(pipelineRuns.items) ? pipelineRuns.items : [])
  .map((item) => {
    const condition = conditionOf(item);
    return {
      name: item?.metadata?.name || "",
      createdAt: item?.metadata?.creationTimestamp || null,
      ageMinutes: ageMinutes(item?.metadata?.creationTimestamp),
      status: condition.status || null,
      reason: condition.reason || null,
    };
  })
  .filter((item) => pipelineRunPrefix.length === 0 || item.name.startsWith(pipelineRunPrefix + "-"));

const protectedActivePipelineRuns = allPipelineRuns
  .filter((item) => item.status !== "True" && item.status !== "False")
  .sort((a, b) => String(a.createdAt).localeCompare(String(b.createdAt)));
const protectedLatestPipelineRun = allPipelineRuns
  .filter((item) => item.status === "True" || item.status === "False")
  .sort((a, b) => String(b.createdAt).localeCompare(String(a.createdAt)))[0]?.name || null;

const candidates = allPipelineRuns
  .filter((item) => item.status === "True" || item.status === "False")
  .filter((item) => typeof item.ageMinutes === "number" && item.ageMinutes >= minAgeMinutes)
  .sort((a, b) => String(a.createdAt).localeCompare(String(b.createdAt)))
  .slice(0, limit)
  .map((item) => {
    const owned = pvcsByOwner.get(item.name) || [];
    const activeMountPods = owned.flatMap((pvc) => activeClaimPods.get(pvc?.metadata?.name) || []);
    const protectedLatest = item.name === protectedLatestPipelineRun;
    return {
      ...item,
      selected: activeMountPods.length === 0 && !protectedLatest,
      selectedReason: protectedLatest ? "protected-latest-pipelinerun" : activeMountPods.length === 0 ? "terminal-and-unmounted" : "owned-pvc-active-mounted",
      ownedPvcCount: owned.length,
      activeMountPods,
    };
  });

const selectedPipelineRuns = candidates.filter((item) => item.selected).map((item) => item.name);
const selectedSet = new Set(selectedPipelineRuns);
const ownedPvcs = [];
const protectedOwnedPvcs = [];
for (const [owner, items] of pvcsByOwner.entries()) {
  if (!selectedSet.has(owner) && !candidates.some((item) => item.name === owner)) continue;
  for (const pvc of items) {
    const volume = pvc?.spec?.volumeName || null;
    const pv = volume ? pvByName.get(volume) : null;
    const hostPath = localPathOf(pv);
    const activeMountPods = activeClaimPods.get(pvc?.metadata?.name) || [];
    const entry = {
      name: pvc?.metadata?.name || null,
      volume,
      phase: pvc?.status?.phase || null,
      ownerKind: "PipelineRun",
      owner,
      storageClass: pv?.spec?.storageClassName || null,
      reclaimPolicy: pv?.spec?.persistentVolumeReclaimPolicy || null,
      hostPath,
      estimatedBytes: duBytes(hostPath),
      activeMountPods,
    };
    if (selectedSet.has(owner)) ownedPvcs.push(entry);
    else protectedOwnedPvcs.push(entry);
  }
}

const estimatedReclaimBytes = ownedPvcs.reduce((sum, item) => sum + (Number.isFinite(item.estimatedBytes) ? item.estimatedBytes : 0), 0);
console.log(JSON.stringify({
  ok: true,
  planKind: "agentrun-ci-completed-pipelinerun-workspace-retention",
  generatedAt: new Date().toISOString(),
  namespace,
  criteria: { prefix: pipelineRunPrefix + "-", terminalStatuses: ["True", "False"], minAgeMinutes, limit },
  candidates,
  candidateCount: candidates.length,
  protectedActivePipelineRuns,
  protectedActivePipelineRunCount: protectedActivePipelineRuns.length,
  protectedLatestPipelineRun,
  selectedPipelineRuns,
  selectedPipelineRunCount: selectedPipelineRuns.length,
  ownedPvcs,
  ownedPvcCount: ownedPvcs.length,
  protectedOwnedPvcs,
  protectedOwnedPvcCount: protectedOwnedPvcs.length,
  estimatedReclaimBytes,
  estimatedReclaimHuman: formatBytes(estimatedReclaimBytes),
}));
`;
}

export function cleanupRunsFinalizeNodeScript(): string {
  return String.raw`
const fs = require("node:fs");
const path = require("node:path");
const tmp = process.env.TMP_DIR;
const deleteExit = Number(process.env.DELETE_EXIT || 0);
const plan = JSON.parse(fs.readFileSync(path.join(tmp, "plan.json"), "utf8"));
const pvcsAfter = JSON.parse(fs.readFileSync(path.join(tmp, "pvcs-after.json"), "utf8"));
const selected = new Set(Array.isArray(plan.selectedPipelineRuns) ? plan.selectedPipelineRuns : []);
const remainingOwnedPvcs = (Array.isArray(pvcsAfter.items) ? pvcsAfter.items : [])
  .map((pvc) => {
    const owner = (Array.isArray(pvc?.metadata?.ownerReferences) ? pvc.metadata.ownerReferences : []).find((entry) => entry.kind === "PipelineRun");
    return {
      name: pvc?.metadata?.name || null,
      volume: pvc?.spec?.volumeName || null,
      phase: pvc?.status?.phase || null,
      ownerKind: owner?.kind || null,
      owner: owner?.name || null,
    };
  })
  .filter((item) => item.owner && selected.has(item.owner));

function tail(name) {
  try {
    const text = fs.readFileSync(path.join(tmp, name), "utf8");
    return text.length > 3000 ? text.slice(-3000) : text;
  } catch {
    return "";
  }
}

console.log(JSON.stringify({
  ...plan,
  ok: deleteExit === 0,
  deletedPipelineRuns: Array.from(selected),
  deletedPipelineRunCount: selected.size,
  deletion: { exitCode: deleteExit, stdoutTail: tail("delete.out"), stderrTail: tail("delete.err") },
  remainingOwnedPvcs,
  remainingOwnedPvcCount: remainingOwnedPvcs.length,
}));
`;
}

export function cleanupReleasedPvsPlanNodeScript(): string {
  return String.raw`
const fs = require("node:fs");
const path = require("node:path");
const cp = require("node:child_process");
const tmp = process.env.TMP_DIR;
const namespace = process.env.NAMESPACE;
const limit = Number(process.env.LIMIT || 20);

function readJson(name) {
  return JSON.parse(fs.readFileSync(path.join(tmp, name), "utf8"));
}

function localPathOf(pv) {
  return pv?.spec?.local?.path || pv?.spec?.hostPath?.path || null;
}

function duBytes(hostPath) {
  if (typeof hostPath !== "string" || !hostPath.startsWith("/var/lib/rancher/k3s/storage/")) return null;
  try {
    const out = cp.execFileSync("du", ["-sB1", hostPath], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
    const bytes = Number(String(out).trim().split(/\s+/)[0]);
    return Number.isFinite(bytes) ? bytes : null;
  } catch {
    return null;
  }
}

function formatBytes(bytes) {
  if (!Number.isFinite(bytes)) return null;
  const units = ["B", "KiB", "MiB", "GiB", "TiB"];
  let value = bytes;
  let unit = 0;
  while (value >= 1024 && unit < units.length - 1) {
    value /= 1024;
    unit += 1;
  }
  return value.toFixed(unit === 0 ? 0 : 1) + units[unit];
}

const pvs = readJson("pvs.json");
const candidates = (Array.isArray(pvs.items) ? pvs.items : [])
  .map((pv) => {
    const hostPath = localPathOf(pv);
    return {
      name: pv?.metadata?.name || "",
      createdAt: pv?.metadata?.creationTimestamp || null,
      phase: pv?.status?.phase || null,
      storageClass: pv?.spec?.storageClassName || null,
      reclaimPolicy: pv?.spec?.persistentVolumeReclaimPolicy || null,
      claimNamespace: pv?.spec?.claimRef?.namespace || null,
      claimName: pv?.spec?.claimRef?.name || null,
      capacity: pv?.spec?.capacity?.storage || null,
      hostPath,
      estimatedBytes: duBytes(hostPath),
    };
  })
  .filter((item) => item.phase === "Released")
  .filter((item) => item.storageClass === "local-path" && item.reclaimPolicy === "Delete")
  .filter((item) => item.claimNamespace === namespace)
  .sort((a, b) => String(a.createdAt).localeCompare(String(b.createdAt)))
  .slice(0, limit);

const selectedPersistentVolumes = candidates.map((item) => item.name);
const estimatedReclaimBytes = candidates.reduce((sum, item) => sum + (Number.isFinite(item.estimatedBytes) ? item.estimatedBytes : 0), 0);
console.log(JSON.stringify({
  ok: true,
  planKind: "agentrun-ci-released-local-path-pv-retention",
  generatedAt: new Date().toISOString(),
  namespace,
  criteria: { phase: "Released", storageClass: "local-path", reclaimPolicy: "Delete", claimNamespace: namespace, limit },
  candidates,
  candidateCount: candidates.length,
  selectedPersistentVolumes,
  selectedPersistentVolumeCount: selectedPersistentVolumes.length,
  estimatedReclaimBytes,
  estimatedReclaimHuman: formatBytes(estimatedReclaimBytes),
}));
`;
}

export function cleanupReleasedPvsFinalizeNodeScript(): string {
  return String.raw`
const fs = require("node:fs");
const path = require("node:path");
const tmp = process.env.TMP_DIR;
const deleteExit = Number(process.env.DELETE_EXIT || 0);
const plan = JSON.parse(fs.readFileSync(path.join(tmp, "plan.json"), "utf8"));
const pvsAfter = JSON.parse(fs.readFileSync(path.join(tmp, "pvs-after.json"), "utf8"));
const selected = new Set(Array.isArray(plan.selectedPersistentVolumes) ? plan.selectedPersistentVolumes : []);
const remainingPersistentVolumes = (Array.isArray(pvsAfter.items) ? pvsAfter.items : [])
  .filter((pv) => selected.has(pv?.metadata?.name))
  .map((pv) => ({ name: pv?.metadata?.name || null, phase: pv?.status?.phase || null, claimNamespace: pv?.spec?.claimRef?.namespace || null, claimName: pv?.spec?.claimRef?.name || null }));

function tail(name) {
  try {
    const text = fs.readFileSync(path.join(tmp, name), "utf8");
    return text.length > 3000 ? text.slice(-3000) : text;
  } catch {
    return "";
  }
}

console.log(JSON.stringify({
  ...plan,
  ok: deleteExit === 0,
  deletedPersistentVolumes: Array.from(selected),
  deletedPersistentVolumeCount: selected.size,
  deletion: { exitCode: deleteExit, stdoutTail: tail("delete.out"), stderrTail: tail("delete.err") },
  remainingPersistentVolumes,
  remainingPersistentVolumeCount: remainingPersistentVolumes.length,
}));
`;
}

export function refreshYamlLaneScript(spec: AgentRunLaneSpec): string {
  return [
    "set -eu",
    `namespace=${shQuote(spec.gitops.argoNamespace)}`,
    `application=${shQuote(spec.gitops.argoApplication)}`,
    "kubectl -n \"$namespace\" annotate application \"$application\" argocd.argoproj.io/refresh=hard --overwrite >/tmp/agentrun-refresh-output.txt",
    "kubectl -n \"$namespace\" get application \"$application\" -o json >/tmp/agentrun-refresh-app.json",
    "NAMESPACE=\"$namespace\" APPLICATION=\"$application\" node <<'NODE'",
    "const fs = require('node:fs');",
    "let app = {};",
    "try { app = JSON.parse(fs.readFileSync('/tmp/agentrun-refresh-app.json', 'utf8')); } catch {}",
    "console.log(JSON.stringify({",
    "  ok: true,",
    "  namespace: process.env.NAMESPACE,",
    "  application: process.env.APPLICATION,",
    "  revision: app.status?.sync?.revision ?? null,",
    "  syncStatus: app.status?.sync?.status ?? null,",
    "  healthStatus: app.status?.health?.status ?? null,",
    "  observedAt: new Date().toISOString(),",
    "  valuesPrinted: false",
    "}));",
    "NODE",
  ].join("\n");
}

export async function gitMirrorStatus(config: UniDeskConfig, options: GitMirrorStatusOptions = { full: false, raw: false, node: null, lane: null }): Promise<Record<string, unknown>> {
  const target = resolveAgentRunLaneTarget(options);
  const spec = target.spec;
  const observation = await readGitMirrorStatus(config, target);
  const summary = observation.summary;
  return {
    ok: observation.ok,
    command: "agentrun git-mirror status",
    mode: "yaml-declared-node-lane",
    configPath: target.configPath,
    target: agentRunLaneSummary(spec),
    namespace: spec.gitMirror.namespace,
    readUrl: spec.gitMirror.readUrl,
    writeUrl: spec.gitMirror.writeUrl,
    summary,
    ...(options.raw ? { raw: observation.raw } : {}),
    probe: compactCapture(observation.result, { full: options.full || options.raw, stdoutTailChars: 6000, stderrTailChars: 3000 }),
    disclosure: {
      defaultView: "compact-low-noise",
      full: options.full,
      raw: options.raw,
      rawOmitted: !options.raw,
      probeTailOmitted: !(options.full || options.raw),
      expandWith: `bun scripts/cli.ts agentrun git-mirror status --node ${spec.nodeId} --lane ${spec.lane} --full`,
      rawWith: `bun scripts/cli.ts agentrun git-mirror status --node ${spec.nodeId} --lane ${spec.lane} --raw`,
    },
    next: {
      sync: `bun scripts/cli.ts agentrun git-mirror sync --node ${spec.nodeId} --lane ${spec.lane} --confirm`,
      flush: summary.pendingFlush === true ? `bun scripts/cli.ts agentrun git-mirror flush --node ${spec.nodeId} --lane ${spec.lane} --confirm` : null,
    },
  };
}
