import { existsSync, readFileSync } from "node:fs";

import { runCommand } from "./command";
import { type UniDeskConfig, repoRoot, rootPath } from "./config";
import { loadCiCatalog, type CiCatalogArtifact } from "./ci-catalog";

type RiskLevel = "low" | "medium" | "high" | "blocked";

export interface DockerImageInventoryItem {
  id: string;
  repoTags: string[];
  repoDigests: string[];
  sizeBytes: number;
  createdAt: string | null;
  labels: Record<string, string>;
}

export interface DockerContainerInventoryItem {
  id: string;
  name: string;
  imageRef: string;
  imageId: string;
  state: string;
  status: string;
  labels: Record<string, string>;
}

export interface DesiredImageRef {
  ref: string;
  source: string;
  serviceId?: string;
  reason: string;
}

export interface ProtectedStorageRef {
  kind: "docker-volume" | "path" | "policy";
  ref: string;
  risk: RiskLevel;
  reason: string;
}

export interface DockerCleanupInventory {
  observedAt: string;
  images: DockerImageInventoryItem[];
  containers: DockerContainerInventoryItem[];
  desiredImageRefs: DesiredImageRef[];
  desiredCommitsByService: Record<string, string[]>;
  protectedStorage: ProtectedStorageRef[];
  collection: {
    dockerAvailable: boolean;
    readOnlyCommands: string[][];
    errors: Array<{ command?: string[]; message: string; exitCode?: number | null; stderrTail?: string }>;
  };
}

export interface ServerCleanupPlanOptions {
  minAgeHours: number;
  limit: number;
  confirm: boolean;
  includeHighRisk: boolean;
}

export interface CleanupImageSummary {
  id: string;
  shortId: string;
  repoTags: string[];
  repoDigests: string[];
  sizeBytes: number;
  createdAt: string | null;
}

export interface ProtectedImageSummary extends CleanupImageSummary {
  risk: "blocked";
  reasons: string[];
  containers: Array<{ id: string; name: string; state: string; status: string; imageRef: string }>;
}

export interface CandidateImageSummary extends CleanupImageSummary {
  risk: Exclude<RiskLevel, "blocked">;
  ageHours: number | null;
  reasons: string[];
  commandsToReview: string[][];
  reviewChecklist: string[];
}

export interface CleanupCommandReview {
  kind: "docker-image-remove";
  risk: Exclude<RiskLevel, "blocked">;
  imageId: string;
  estimatedReclaimBytes: number;
  command: string[];
  commandText: string;
  requiresManualApproval: true;
  reviewChecklist: string[];
}

interface DiskSnapshot {
  filesystem: string;
  sizeBytes: number;
  usedBytes: number;
  availableBytes: number;
  usePercent: number;
  mount: string;
}

export interface ServerCleanupPlan {
  ok: boolean;
  dryRun: true;
  mutation: false;
  action: "server cleanup plan";
  scope: "docker-images-only";
  observedAt: string;
  options: ServerCleanupPlanOptions;
  policy: {
    deletionExecuted: false;
    deleteCommandsExecuted: [];
    dockerPruneUsed: false;
    dockerVolumesTouched: false;
    dataDirectoriesTouched: false;
    databaseCleanupIncluded: false;
    liveCleanupImplemented: boolean;
    note: string;
  };
  inventory: {
    dockerAvailable: boolean;
    imageCount: number;
    containerCount: number;
    activeContainerCount: number;
    candidateImageCount: number;
    protectedImageCount: number;
    omittedCandidateCount: number;
    collectionErrors: DockerCleanupInventory["collection"]["errors"];
    readOnlyCommands: string[][];
  };
  activeContainers: Array<{ id: string; name: string; imageRef: string; imageId: string; status: string }>;
  activeImages: ProtectedImageSummary[];
  protectedImages: ProtectedImageSummary[];
  protectedDesiredImageRefs: DesiredImageRef[];
  protectedStorage: ProtectedStorageRef[];
  candidateStaleImages: CandidateImageSummary[];
  estimatedReclaimBytes: number;
  estimates: {
    candidateImageCount: number;
    estimatedReclaimBytes: number;
    estimatedReclaimBytesUpperBound: number;
    basis: string;
    warning: string;
  };
  risk: {
    overall: "manual-review-required" | "no-candidates";
    low: number;
    medium: number;
    high: number;
    blocked: number;
  };
  commandsToReview: CleanupCommandReview[];
  manualApprovalRequired: string[];
  prohibitedCommands: string[];
}

interface ServerCleanupRun {
  ok: boolean;
  dryRun: false;
  mutation: true;
  action: "server cleanup run";
  scope: "docker-images-only";
  observedAt: string;
  options: ServerCleanupPlanOptions;
  diskBefore: DiskSnapshot | null;
  diskAfter: DiskSnapshot | null;
  summary: {
    plannedCandidateCount: number;
    attemptedCount: number;
    succeededCount: number;
    failedCount: number;
    skippedHighRiskCount: number;
    estimatedReclaimBytes: number;
    actualDiskReclaimBytes: number | null;
  };
  results: Array<{
    imageId: string;
    shortId: string;
    repoTags: string[];
    repoDigests: string[];
    risk: Exclude<RiskLevel, "blocked">;
    estimatedReclaimBytes: number;
    command: string[];
    status: "succeeded" | "failed" | "skipped";
    reason?: string;
    exitCode?: number | null;
    stdoutTail?: string;
    stderrTail?: string;
  }>;
  policy: {
    deletionExecuted: true;
    dockerPruneUsed: false;
    dockerVolumesTouched: false;
    dataDirectoriesTouched: false;
    databaseCleanupIncluded: false;
    highRiskRequiresIncludeFlag: true;
  };
}

interface DeployServiceCommit {
  environment: string;
  serviceId: string;
  commitId: string;
}

const defaultOptions: ServerCleanupPlanOptions = {
  minAgeHours: 24,
  limit: 200,
  confirm: false,
  includeHighRisk: false,
};

export function parseServerCleanupOptions(args: string[]): ServerCleanupPlanOptions {
  const options = { ...defaultOptions };
  for (let index = 0; index < args.length; index += 1) {
    const arg = args[index];
    if (arg === "--min-age-hours") {
      const raw = args[index + 1];
      const value = Number(raw);
      if (!Number.isFinite(value) || value < 0) throw new Error("--min-age-hours must be a non-negative number");
      options.minAgeHours = value;
      index += 1;
    } else if (arg === "--limit") {
      const raw = args[index + 1];
      const value = Number(raw);
      if (!Number.isInteger(value) || value <= 0) throw new Error("--limit must be a positive integer");
      options.limit = Math.min(value, 1000);
      index += 1;
    } else if (arg === "--confirm") {
      options.confirm = true;
    } else if (arg === "--dry-run") {
      options.confirm = false;
    } else if (arg === "--include-high-risk") {
      options.includeHighRisk = true;
    } else if (arg === "--no-high-risk") {
      options.includeHighRisk = false;
    } else {
      throw new Error(`unknown server cleanup option: ${arg}`);
    }
  }
  return options;
}

export async function runServerCleanupCommand(config: UniDeskConfig, args: string[]): Promise<unknown> {
  const [action = "plan", ...rest] = args;
  if (action === "plan" || action === "dry-run") {
    return serverCleanupPlan(config, parseServerCleanupOptions(rest));
  }
  if (action === "run") {
    const options = parseServerCleanupOptions(rest);
    if (!options.confirm) {
      return {
        ok: false,
        error: "server-cleanup-run-requires-confirm",
        dryRun: true,
        mutation: false,
        next: {
          plan: `bun scripts/cli.ts server cleanup plan --min-age-hours ${options.minAgeHours} --limit ${options.limit}`,
          confirm: `bun scripts/cli.ts server cleanup run --confirm --min-age-hours ${options.minAgeHours} --limit ${options.limit}`,
        },
        policy: "server cleanup run removes only listed stale Docker images; high-risk images require --include-high-risk.",
      };
    }
    return serverCleanupRun(config, options);
  }
  return {
    ok: false,
    error: "unsupported-server-cleanup-action",
    action,
    supportedActions: ["plan", "run"],
    dryRunOnly: true,
    mutation: false,
    policy: "server cleanup run requires --confirm and removes only stale Docker images selected by the same plan classifier.",
  };
}

export function serverCleanupPlan(config: UniDeskConfig, options: ServerCleanupPlanOptions = defaultOptions): ServerCleanupPlan {
  const inventory = collectDockerCleanupInventory(config);
  return buildDockerCleanupPlan(inventory, options);
}

export function buildDockerCleanupPlan(inventory: DockerCleanupInventory, options: ServerCleanupPlanOptions = defaultOptions): ServerCleanupPlan {
  const normalizedDesiredRefs = new Map<string, DesiredImageRef[]>();
  for (const desired of inventory.desiredImageRefs) {
    for (const variant of imageRefVariants(desired.ref)) {
      const existing = normalizedDesiredRefs.get(variant) ?? [];
      existing.push(desired);
      normalizedDesiredRefs.set(variant, existing);
    }
  }

  const containersByImage = new Map<string, DockerContainerInventoryItem[]>();
  for (const container of inventory.containers) {
    if (container.imageId.length === 0) continue;
    const existing = containersByImage.get(container.imageId) ?? [];
    existing.push(container);
    containersByImage.set(container.imageId, existing);
  }

  const runningImageIds = new Set(
    inventory.containers
      .filter((container) => container.state === "running")
      .map((container) => container.imageId)
      .filter(Boolean),
  );

  const protectedImages: ProtectedImageSummary[] = [];
  const candidateImages: CandidateImageSummary[] = [];
  const commandsToReview: CleanupCommandReview[] = [];

  for (const image of inventory.images) {
    const relatedContainers = containersByImage.get(image.id) ?? [];
    const reasons = protectedReasonsForImage(image, relatedContainers, runningImageIds, normalizedDesiredRefs, inventory.desiredCommitsByService);
    if (reasons.length > 0) {
      protectedImages.push({
        ...imageSummary(image),
        risk: "blocked",
        reasons,
        containers: relatedContainers.map(containerSummary),
      });
      continue;
    }

    const ageHours = imageAgeHours(image.createdAt, inventory.observedAt);
    if (ageHours !== null && ageHours < options.minAgeHours) {
      protectedImages.push({
        ...imageSummary(image),
        risk: "blocked",
        reasons: [`younger-than-min-age-hours:${options.minAgeHours}`],
        containers: [],
      });
      continue;
    }

    const risk = candidateRisk(image, ageHours);
    const reviewChecklist = reviewChecklistForCandidate(risk, image);
    const command = imageRemoveCommand(image);
    const candidate: CandidateImageSummary = {
      ...imageSummary(image),
      risk,
      ageHours,
      reasons: candidateReasons(image, ageHours),
      commandsToReview: [command],
      reviewChecklist,
    };
    candidateImages.push(candidate);
    commandsToReview.push({
      kind: "docker-image-remove",
      risk,
      imageId: image.id,
      estimatedReclaimBytes: image.sizeBytes,
      command,
      commandText: command.map(shellQuote).join(" "),
      requiresManualApproval: true,
      reviewChecklist,
    });
  }

  candidateImages.sort((left, right) => right.sizeBytes - left.sizeBytes || left.shortId.localeCompare(right.shortId));
  commandsToReview.sort((left, right) => right.estimatedReclaimBytes - left.estimatedReclaimBytes || left.imageId.localeCompare(right.imageId));
  protectedImages.sort((left, right) => right.sizeBytes - left.sizeBytes || left.shortId.localeCompare(right.shortId));

  const visibleCandidates = candidateImages.slice(0, options.limit);
  const visibleCommands = commandsToReview.slice(0, options.limit);
  const activeImages = protectedImages.filter((image) => runningImageIds.has(image.id));
  const estimatedReclaimBytes = candidateImages.reduce((total, image) => total + image.sizeBytes, 0);
  const riskCounts = candidateImages.reduce(
    (counts, image) => ({ ...counts, [image.risk]: counts[image.risk] + 1 }),
    { low: 0, medium: 0, high: 0 },
  );

  return {
    ok: inventory.collection.dockerAvailable && inventory.collection.errors.length === 0,
    dryRun: true,
    mutation: false,
    action: "server cleanup plan",
    scope: "docker-images-only",
    observedAt: inventory.observedAt,
    options,
    policy: {
      deletionExecuted: false,
      deleteCommandsExecuted: [],
      dockerPruneUsed: false,
      dockerVolumesTouched: false,
      dataDirectoriesTouched: false,
      databaseCleanupIncluded: false,
      liveCleanupImplemented: true,
      note: "This command inventories Docker images and builds a dry-run review plan. Confirmed execution is available through server cleanup run --confirm; it never runs docker prune, docker volume rm, rm, or database cleanup.",
    },
    inventory: {
      dockerAvailable: inventory.collection.dockerAvailable,
      imageCount: inventory.images.length,
      containerCount: inventory.containers.length,
      activeContainerCount: inventory.containers.filter((container) => container.state === "running").length,
      candidateImageCount: candidateImages.length,
      protectedImageCount: protectedImages.length,
      omittedCandidateCount: Math.max(0, candidateImages.length - visibleCandidates.length),
      collectionErrors: inventory.collection.errors,
      readOnlyCommands: inventory.collection.readOnlyCommands,
    },
    activeContainers: inventory.containers
      .filter((container) => container.state === "running")
      .map((container) => ({
        id: shortContainerId(container.id),
        name: container.name,
        imageRef: container.imageRef,
        imageId: container.imageId,
        status: container.status,
      })),
    activeImages,
    protectedImages,
    protectedDesiredImageRefs: inventory.desiredImageRefs,
    protectedStorage: inventory.protectedStorage,
    candidateStaleImages: visibleCandidates,
    estimatedReclaimBytes,
    estimates: {
      candidateImageCount: candidateImages.length,
      estimatedReclaimBytes,
      estimatedReclaimBytesUpperBound: estimatedReclaimBytes,
      basis: "sum of docker image inspect .Size for candidate image IDs, counted once per image ID",
      warning: "Docker layers may be shared; actual reclaim can be lower. Review docker system df before any future approved deletion.",
    },
    risk: {
      overall: candidateImages.length === 0 ? "no-candidates" : "manual-review-required",
      low: riskCounts.low,
      medium: riskCounts.medium,
      high: riskCounts.high,
      blocked: protectedImages.length,
    },
    commandsToReview: visibleCommands,
    manualApprovalRequired: [
      "Review every candidate image ID, tag, age, and risk before running any command outside this dry-run.",
      "Confirm the target is not a rollback artifact, emergency diagnostic image, or active provider/runtime dependency.",
      "Do not clean PostgreSQL, Baidu Netdisk state, registry storage, Docker volumes, or host data directories from this plan.",
      "Any future database cleanup requires a verified backup first; this plan deliberately excludes database cleanup.",
    ],
    prohibitedCommands: [
      "docker system prune",
      "docker image prune",
      "docker builder prune",
      "docker volume rm",
      "docker compose down -v",
      "rm -rf .state",
      "rm -rf /home/ubuntu/.unidesk/registry-storage",
    ],
  };
}

export function serverCleanupRun(config: UniDeskConfig, options: ServerCleanupPlanOptions = defaultOptions): ServerCleanupRun {
  const diskBefore = rootDiskSnapshot();
  const plan = serverCleanupPlan(config, options);
  const selected = plan.candidateStaleImages;
  const results: ServerCleanupRun["results"] = [];

  for (const image of selected) {
    const command = image.commandsToReview[0] ?? imageRemoveCommand({
      id: image.id,
      repoTags: image.repoTags,
      repoDigests: image.repoDigests,
      sizeBytes: image.sizeBytes,
      createdAt: image.createdAt,
      labels: {},
    });
    if (image.risk === "high" && !options.includeHighRisk) {
      results.push({
        imageId: image.id,
        shortId: image.shortId,
        repoTags: image.repoTags,
        repoDigests: image.repoDigests,
        risk: image.risk,
        estimatedReclaimBytes: image.sizeBytes,
        command,
        status: "skipped",
        reason: "high-risk-requires-include-high-risk",
      });
      continue;
    }
    const remove = runCommand(command, repoRoot, { timeoutMs: 60_000 });
    const presentAfterRemove = remove.exitCode === 0 ? false : dockerImagePresent(image.id);
    const succeeded = remove.exitCode === 0 || presentAfterRemove === false;
    results.push({
      imageId: image.id,
      shortId: image.shortId,
      repoTags: image.repoTags,
      repoDigests: image.repoDigests,
      risk: image.risk,
      estimatedReclaimBytes: image.sizeBytes,
      command,
      status: succeeded ? "succeeded" : "failed",
      reason: remove.exitCode !== 0 && presentAfterRemove === false ? "image-absent-after-remove" : undefined,
      exitCode: remove.exitCode,
      stdoutTail: tailText(remove.stdout, 4000),
      stderrTail: tailText(remove.stderr, 4000),
    });
  }

  const diskAfter = rootDiskSnapshot();
  const failedCount = results.filter((item) => item.status === "failed").length;
  const succeededCount = results.filter((item) => item.status === "succeeded").length;
  const skippedHighRiskCount = results.filter((item) => item.status === "skipped").length;
  const attempted = results.filter((item) => item.status !== "skipped");
  const actualDiskReclaimBytes = diskBefore !== null && diskAfter !== null ? diskAfter.availableBytes - diskBefore.availableBytes : null;
  return {
    ok: plan.ok && failedCount === 0,
    dryRun: false,
    mutation: true,
    action: "server cleanup run",
    scope: "docker-images-only",
    observedAt: new Date().toISOString(),
    options,
    diskBefore,
    diskAfter,
    summary: {
      plannedCandidateCount: selected.length,
      attemptedCount: attempted.length,
      succeededCount,
      failedCount,
      skippedHighRiskCount,
      estimatedReclaimBytes: attempted.reduce((sum, item) => sum + item.estimatedReclaimBytes, 0),
      actualDiskReclaimBytes,
    },
    results,
    policy: {
      deletionExecuted: true,
      dockerPruneUsed: false,
      dockerVolumesTouched: false,
      dataDirectoriesTouched: false,
      databaseCleanupIncluded: false,
      highRiskRequiresIncludeFlag: true,
    },
  };
}

function collectDockerCleanupInventory(config: UniDeskConfig): DockerCleanupInventory {
  const observedAt = new Date().toISOString();
  const desired = collectDesiredImagePolicy(config);
  const readOnlyCommands: string[][] = [];
  const errors: DockerCleanupInventory["collection"]["errors"] = [];
  const images = collectDockerImages(readOnlyCommands, errors);
  const containers = collectDockerContainers(readOnlyCommands, errors);
  return {
    observedAt,
    images,
    containers,
    desiredImageRefs: desired.refs,
    desiredCommitsByService: desired.commitsByService,
    protectedStorage: protectedStorageRefs(config),
    collection: {
      dockerAvailable: errors.length === 0 || images.length > 0 || containers.length > 0,
      readOnlyCommands,
      errors,
    },
  };
}

function collectDockerImages(
  readOnlyCommands: string[][],
  errors: DockerCleanupInventory["collection"]["errors"],
): DockerImageInventoryItem[] {
  const listCommand = ["docker", "image", "ls", "-q", "--no-trunc"];
  readOnlyCommands.push(listCommand);
  const list = runCommand(listCommand, repoRoot, { timeoutMs: 30_000 });
  if (list.exitCode !== 0) {
    errors.push(commandError(listCommand, "failed to list docker images", list.exitCode, list.stderr));
    return [];
  }
  const imageIds = [...new Set(list.stdout.split("\n").map((line) => line.trim()).filter(Boolean))];
  const images: DockerImageInventoryItem[] = [];
  for (const chunk of chunks(imageIds, 50)) {
    const inspectCommand = ["docker", "image", "inspect", ...chunk];
    readOnlyCommands.push(["docker", "image", "inspect", `<${chunk.length} image ids>`]);
    const inspect = runCommand(inspectCommand, repoRoot, { timeoutMs: 60_000 });
    if (inspect.exitCode !== 0) {
      errors.push(commandError(inspectCommand, "failed to inspect docker images", inspect.exitCode, inspect.stderr));
      continue;
    }
    const parsed = parseJsonArray(inspect.stdout, "docker image inspect");
    for (const item of parsed) images.push(imageFromInspect(item));
  }
  return images;
}

function collectDockerContainers(
  readOnlyCommands: string[][],
  errors: DockerCleanupInventory["collection"]["errors"],
): DockerContainerInventoryItem[] {
  const psCommand = ["docker", "ps", "-a", "--no-trunc", "--format", "{{json .}}"];
  readOnlyCommands.push(psCommand);
  const ps = runCommand(psCommand, repoRoot, { timeoutMs: 30_000 });
  if (ps.exitCode !== 0) {
    errors.push(commandError(psCommand, "failed to list docker containers", ps.exitCode, ps.stderr));
    return [];
  }
  const ids = ps.stdout
    .split("\n")
    .map((line) => {
      const record = parseJsonRecord(line, "docker ps");
      return stringValue(record.ID);
    })
    .filter(Boolean);
  if (ids.length === 0) return [];
  const containers: DockerContainerInventoryItem[] = [];
  for (const chunk of chunks(ids, 50)) {
    const inspectCommand = ["docker", "container", "inspect", ...chunk];
    readOnlyCommands.push(["docker", "container", "inspect", `<${chunk.length} container ids>`]);
    const inspect = runCommand(inspectCommand, repoRoot, { timeoutMs: 60_000 });
    if (inspect.exitCode !== 0) {
      errors.push(commandError(inspectCommand, "failed to inspect docker containers", inspect.exitCode, inspect.stderr));
      continue;
    }
    const parsed = parseJsonArray(inspect.stdout, "docker container inspect");
    for (const item of parsed) containers.push(containerFromInspect(item));
  }
  return containers;
}

function collectDesiredImagePolicy(config: UniDeskConfig): { refs: DesiredImageRef[]; commitsByService: Record<string, string[]> } {
  const refs: DesiredImageRef[] = [];
  const commits = deployServiceCommits();
  const commitsByService = commits.reduce<Record<string, string[]>>((accumulator, item) => {
    accumulator[item.serviceId] = [...(accumulator[item.serviceId] ?? []), item.commitId];
    return accumulator;
  }, {});
  const add = (ref: string | undefined, source: string, reason: string, serviceId?: string): void => {
    if (ref === undefined || ref.trim().length === 0) return;
    refs.push({ ref: ref.trim(), source, reason, ...(serviceId === undefined ? {} : { serviceId }) });
  };

  for (const service of composeServiceImageRefs(config)) {
    add(service.ref, "docker-compose.yml", service.reason, service.serviceId);
  }
  add(config.providerGateway.upgrade.runnerImage, "config.providerGateway.upgrade.runnerImage", "provider-gateway remote upgrade runner image", "provider-gateway");

  for (const microservice of config.microservices) {
    add(microservice.repository.artifactSource?.imageRef, "config.microservices.repository.artifactSource", "upstream image pin", microservice.id);
    add(microservice.repository.artifactSource?.mirrorRepository, "config.microservices.repository.artifactSource", "upstream mirror repository", microservice.id);
    if (looksLikeImageRef(microservice.repository.dockerfile)) {
      add(microservice.repository.dockerfile, "config.microservices.repository.dockerfile", "repository dockerfile field is an upstream image reference", microservice.id);
    }
    if (microservice.providerId === config.providerGateway.id || microservice.deployment.mode === "internal-sidecar") {
      add(microservice.repository.composeService, "config.microservices.repository.composeService", "main-server or internal-sidecar stable image", microservice.id);
      add(`unidesk-${microservice.repository.composeService}`, "config.microservices.repository.composeService", "main-server or internal-sidecar Compose default image", microservice.id);
    }
  }

  try {
    const catalog = loadCiCatalog();
    for (const artifact of catalog.artifacts) {
      addCatalogArtifactRefs(artifact, catalog.defaults.registry, commitsByService[artifact.serviceId] ?? [], add);
    }
  } catch (error) {
    add("ci-catalog-unavailable-placeholder", "CI.json", `CI catalog could not be loaded: ${error instanceof Error ? error.message : String(error)}`);
  }

  for (const item of commits) {
    for (const stable of stableImageRefsForService(item.serviceId, config)) {
      add(stable, `deploy.json#environments.${item.environment}`, "current deploy desired stable image", item.serviceId);
      if (!hasTagOrDigest(stable)) {
        add(`${stable}:${item.commitId}`, `deploy.json#environments.${item.environment}`, "current deploy desired commit-tag image", item.serviceId);
      }
    }
  }

  return { refs: dedupeDesiredRefs(refs), commitsByService };
}

function addCatalogArtifactRefs(
  artifact: CiCatalogArtifact,
  registry: string,
  commits: string[],
  add: (ref: string | undefined, source: string, reason: string, serviceId?: string) => void,
): void {
  if (artifact.kind === "source-build") {
    for (const commit of commits) {
      add(`${registry}/${artifact.image.repository}:${commit}`, "CI.json + deploy.json", "current commit-pinned registry artifact", artifact.serviceId);
      add(`${artifact.image.repository}:${commit}`, "CI.json + deploy.json", "current commit-pinned local artifact tag", artifact.serviceId);
    }
    return;
  }
  add(artifact.upstream.imageRef, "CI.json upstream image", "blocked upstream image pin", artifact.serviceId);
  add(artifact.upstream.digestRef, "CI.json upstream image", "blocked upstream image digest pin", artifact.serviceId);
  add(`${registry}/${artifact.upstream.mirrorRepository}:${artifact.upstream.mirrorTag}`, "CI.json upstream image", "upstream mirror tag", artifact.serviceId);
  add(artifact.upstream.mirrorDigestRef, "CI.json upstream image", "upstream mirror digest pin", artifact.serviceId);
}

function composeServiceImageRefs(config: UniDeskConfig): Array<{ ref: string; serviceId?: string; reason: string }> {
  const composePath = rootPath(config.docker.composeFile);
  if (!existsSync(composePath)) return [];
  const raw = readFileSync(composePath, "utf8");
  const refs: Array<{ ref: string; serviceId?: string; reason: string }> = [];
  let inServices = false;
  let currentService: string | null = null;
  let currentHasBuild = false;
  for (const line of raw.split("\n")) {
    if (/^services:\s*$/u.test(line)) {
      inServices = true;
      continue;
    }
    if (inServices && /^[A-Za-z0-9_-]+:\s*$/u.test(line)) break;
    if (!inServices) continue;
    const serviceMatch = line.match(/^  ([A-Za-z0-9_.-]+):\s*$/u);
    if (serviceMatch) {
      if (currentService !== null && currentHasBuild) {
        refs.push({ ref: `${config.docker.projectName}-${currentService}`, serviceId: serviceIdForComposeService(currentService, config), reason: "Compose default build image" });
      }
      currentService = serviceMatch[1];
      currentHasBuild = false;
      continue;
    }
    if (currentService === null) continue;
    if (/^\s{4}build:\s*$/u.test(line)) currentHasBuild = true;
    const imageMatch = line.match(/^\s{4}image:\s*(?:"([^"]+)"|'([^']+)'|([^#\s]+))/u);
    if (imageMatch) {
      refs.push({ ref: imageMatch[1] ?? imageMatch[2] ?? imageMatch[3] ?? "", serviceId: serviceIdForComposeService(currentService, config), reason: "Compose explicit image" });
    }
  }
  if (currentService !== null && currentHasBuild) {
    refs.push({ ref: `${config.docker.projectName}-${currentService}`, serviceId: serviceIdForComposeService(currentService, config), reason: "Compose default build image" });
  }
  return refs.filter((item) => item.ref.length > 0);
}

function serviceIdForComposeService(composeService: string, config: UniDeskConfig): string | undefined {
  if (composeService === "backend-core") return "backend-core";
  if (composeService === "frontend") return "frontend";
  if (composeService === "provider-gateway") return "provider-gateway";
  const microservice = config.microservices.find((item) => item.repository.composeService === composeService);
  return microservice?.id;
}

function deployServiceCommits(): DeployServiceCommit[] {
  const path = rootPath("deploy.json");
  if (!existsSync(path)) return [];
  const parsed = asRecord(JSON.parse(readFileSync(path, "utf8")) as unknown, "deploy.json");
  const environments = asRecord(parsed.environments, "deploy.json.environments");
  const commits: DeployServiceCommit[] = [];
  for (const [environment, envValue] of Object.entries(environments)) {
    const env = asRecord(envValue, `deploy.json.environments.${environment}`);
    const services = Array.isArray(env.services) ? env.services : [];
    for (const [index, serviceValue] of services.entries()) {
      const service = asRecord(serviceValue, `deploy.json.environments.${environment}.services[${index}]`);
      const serviceId = stringValue(service.id);
      const commitId = stringValue(service.commitId);
      if (serviceId.length > 0 && /^[0-9a-f]{40}$/iu.test(commitId)) commits.push({ environment, serviceId, commitId });
    }
  }
  return commits;
}

function stableImageRefsForService(serviceId: string, config: UniDeskConfig): string[] {
  if (serviceId === "backend-core") return ["unidesk-backend-core"];
  if (serviceId === "frontend") return ["unidesk-frontend", "unidesk-frontend:dev"];
  if (serviceId === "code-queue") return ["unidesk-code-queue", "unidesk-code-queue:dev", "unidesk-code-queue:d601"];
  if (serviceId === "k3sctl-adapter") return ["unidesk-k3sctl-adapter", "unidesk-k3sctl-adapter:d601"];
  if (serviceId === "decision-center") return ["unidesk-decision-center", "unidesk-decision-center:dev", "unidesk-decision-center:d601"];
  if (serviceId === "mdtodo") return ["unidesk-mdtodo", "unidesk-mdtodo:dev", "unidesk-mdtodo:d601"];
  if (serviceId === "claudeqq") return ["unidesk-claudeqq", "unidesk-claudeqq:dev", "unidesk-claudeqq:d601"];
  if (serviceId === "findjob") return ["findjob-server"];
  if (serviceId === "pipeline") return ["pipeline-v2-control"];
  if (serviceId === "met-nonlinear") return ["met-nonlinear-ml", "met-nonlinear-ml:tf26"];
  const microservice = config.microservices.find((item) => item.id === serviceId);
  if (microservice === undefined) return [];
  return [microservice.repository.composeService, `unidesk-${microservice.repository.composeService}`];
}

function protectedStorageRefs(config: UniDeskConfig): ProtectedStorageRef[] {
  return [
    {
      kind: "docker-volume",
      ref: config.database.volume,
      risk: "blocked",
      reason: "PostgreSQL PGDATA named volume; database cleanup requires a verified backup and is outside this image-only plan.",
    },
    {
      kind: "path",
      ref: rootPath(".state", "baidu-netdisk"),
      risk: "blocked",
      reason: "Baidu Netdisk OAuth, transfer, and staging state must not be removed by Docker image cleanup.",
    },
    {
      kind: "path",
      ref: "/home/ubuntu/.unidesk/registry-storage",
      risk: "blocked",
      reason: "D601 artifact registry storage is protected; registry cleanup is not part of main-server Docker image cleanup.",
    },
    {
      kind: "policy",
      ref: "docker-volumes-and-host-data",
      risk: "blocked",
      reason: "This plan must not generate docker volume rm, compose down -v, database cleanup, or host data directory removal commands.",
    },
  ];
}

function protectedReasonsForImage(
  image: DockerImageInventoryItem,
  relatedContainers: DockerContainerInventoryItem[],
  runningImageIds: Set<string>,
  desiredRefs: Map<string, DesiredImageRef[]>,
  desiredCommitsByService: Record<string, string[]>,
): string[] {
  const reasons: string[] = [];
  if (runningImageIds.has(image.id)) reasons.push("used-by-running-container");
  if (relatedContainers.some((container) => container.state !== "running")) reasons.push("referenced-by-stopped-container-review-container-first");
  for (const ref of [...image.repoTags, ...image.repoDigests]) {
    const matches = desiredRefs.get(normalizeImageRef(ref)) ?? [];
    for (const match of matches) reasons.push(`desired-image-ref:${match.source}:${match.ref}`);
  }
  const labelService = image.labels["unidesk.ai/service-id"] ?? image.labels["unidesk.service.id"];
  const labelCommit = image.labels["unidesk.ai/source-commit"] ?? image.labels["unidesk.source.commit"] ?? image.labels["org.opencontainers.image.revision"];
  if (labelService !== undefined && labelCommit !== undefined && (desiredCommitsByService[labelService] ?? []).includes(labelCommit)) {
    reasons.push(`desired-label:${labelService}@${labelCommit}`);
  }
  return [...new Set(reasons)];
}

function imageSummary(image: DockerImageInventoryItem): CleanupImageSummary {
  return {
    id: image.id,
    shortId: image.id.replace(/^sha256:/u, "").slice(0, 12),
    repoTags: image.repoTags,
    repoDigests: image.repoDigests,
    sizeBytes: image.sizeBytes,
    createdAt: image.createdAt,
  };
}

function containerSummary(container: DockerContainerInventoryItem): { id: string; name: string; state: string; status: string; imageRef: string } {
  return {
    id: shortContainerId(container.id),
    name: container.name,
    state: container.state,
    status: container.status,
    imageRef: container.imageRef,
  };
}

function candidateRisk(image: DockerImageInventoryItem, ageHours: number | null): Exclude<RiskLevel, "blocked"> {
  const refs = [...image.repoTags, ...image.repoDigests].join(" ");
  const hasServiceLabel = image.labels["unidesk.ai/service-id"] !== undefined || image.labels["unidesk.service.id"] !== undefined;
  if (hasServiceLabel || /(^|[/:-])unidesk([/:_-]|$)/iu.test(refs)) return "high";
  if (image.repoTags.length > 0 || ageHours === null || ageHours < 24) return "medium";
  return "low";
}

function candidateReasons(image: DockerImageInventoryItem, ageHours: number | null): string[] {
  const reasons = ["not-used-by-any-container", "not-present-in-deploy-or-ci-protected-refs"];
  if (image.repoTags.length === 0) reasons.push("dangling-or-untagged-image");
  else reasons.push("tagged-image-requires-extra-review");
  if (ageHours !== null) reasons.push(`age-hours:${Math.round(ageHours * 10) / 10}`);
  return reasons;
}

function reviewChecklistForCandidate(risk: Exclude<RiskLevel, "blocked">, image: DockerImageInventoryItem): string[] {
  const checklist = [
    "Confirm docker ps -a still shows no container using this image ID.",
    "Confirm the image is not a rollback target in deploy.json, CI.json, or recent deployment notes.",
  ];
  if (risk === "high") checklist.push("High risk because the image looks UniDesk/service-related; require explicit operator approval before removal.");
  if (image.repoTags.length > 0) checklist.push("Tagged image: review every tag in the command before approving deletion.");
  return checklist;
}

function imageRemoveCommand(image: DockerImageInventoryItem): string[] {
  const refs = [...image.repoTags, ...image.repoDigests].filter((tag) => tag !== "<none>:<none>" && tag !== "<none>@<none>");
  if (refs.length > 0) return ["docker", "image", "rm", ...refs];
  return ["docker", "image", "rm", image.id];
}

function imageAgeHours(createdAt: string | null, observedAt: string): number | null {
  if (createdAt === null) return null;
  const created = Date.parse(createdAt);
  const observed = Date.parse(observedAt);
  if (!Number.isFinite(created) || !Number.isFinite(observed)) return null;
  return Math.max(0, (observed - created) / 3_600_000);
}

function imageFromInspect(value: unknown): DockerImageInventoryItem {
  const record = asRecord(value, "docker image inspect item");
  const config = asRecord(record.Config ?? {}, "docker image inspect item.Config");
  return {
    id: stringValue(record.Id),
    repoTags: stringArrayValue(record.RepoTags).filter((tag) => tag !== "<none>:<none>"),
    repoDigests: stringArrayValue(record.RepoDigests).filter((digest) => digest !== "<none>@<none>"),
    sizeBytes: numberValue(record.Size),
    createdAt: stringValue(record.Created) || null,
    labels: stringRecordValue(config.Labels),
  };
}

function containerFromInspect(value: unknown): DockerContainerInventoryItem {
  const record = asRecord(value, "docker container inspect item");
  const config = asRecord(record.Config ?? {}, "docker container inspect item.Config");
  const state = asRecord(record.State ?? {}, "docker container inspect item.State");
  return {
    id: stringValue(record.Id),
    name: stringValue(record.Name).replace(/^\//u, ""),
    imageRef: stringValue(config.Image),
    imageId: stringValue(record.Image),
    state: stringValue(state.Status),
    status: stringValue(state.Status),
    labels: stringRecordValue(config.Labels),
  };
}

function parseJsonArray(text: string, label: string): unknown[] {
  const trimmed = text.trim();
  if (trimmed.length === 0) return [];
  const parsed = JSON.parse(trimmed) as unknown;
  if (!Array.isArray(parsed)) throw new Error(`${label} did not return a JSON array`);
  return parsed;
}

function parseJsonRecord(text: string, label: string): Record<string, unknown> {
  const trimmed = text.trim();
  if (trimmed.length === 0) return {};
  return asRecord(JSON.parse(trimmed) as unknown, label);
}

function asRecord(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 stringValue(value: unknown): string {
  return typeof value === "string" ? value : "";
}

function numberValue(value: unknown): number {
  return typeof value === "number" && Number.isFinite(value) ? value : 0;
}

function stringArrayValue(value: unknown): string[] {
  if (!Array.isArray(value)) return [];
  return value.filter((item): item is string => typeof item === "string" && item.length > 0);
}

function stringRecordValue(value: unknown): Record<string, string> {
  if (typeof value !== "object" || value === null || Array.isArray(value)) return {};
  const result: Record<string, string> = {};
  for (const [key, entry] of Object.entries(value)) {
    if (typeof entry === "string") result[key] = entry;
  }
  return result;
}

function chunks<T>(values: T[], size: number): T[][] {
  const result: T[][] = [];
  for (let index = 0; index < values.length; index += size) result.push(values.slice(index, index + size));
  return result;
}

function imageRefVariants(ref: string): string[] {
  const normalized = normalizeImageRef(ref);
  const variants = new Set<string>([normalized]);
  if (!hasTagOrDigest(normalized)) variants.add(`${normalized}:latest`);
  if (normalized.startsWith("docker.io/library/")) variants.add(normalized.replace(/^docker\.io\/library\//u, ""));
  if (normalized.startsWith("docker.io/")) variants.add(normalized.replace(/^docker\.io\//u, ""));
  return [...variants];
}

function normalizeImageRef(ref: string): string {
  return ref.trim().replace(/^docker\.io\/library\//u, "");
}

function hasTagOrDigest(ref: string): boolean {
  if (ref.includes("@")) return true;
  const slashIndex = ref.lastIndexOf("/");
  const colonIndex = ref.lastIndexOf(":");
  return colonIndex > slashIndex;
}

function looksLikeImageRef(value: string): boolean {
  if (value.endsWith("Dockerfile") || value.endsWith(".yml") || value.endsWith(".yaml") || value.endsWith(".json")) return false;
  return value.includes(":") || value.includes("@");
}

function dedupeDesiredRefs(refs: DesiredImageRef[]): DesiredImageRef[] {
  const seen = new Set<string>();
  const result: DesiredImageRef[] = [];
  for (const ref of refs) {
    const key = `${ref.ref}\0${ref.source}\0${ref.reason}\0${ref.serviceId ?? ""}`;
    if (seen.has(key)) continue;
    seen.add(key);
    result.push(ref);
  }
  return result.sort((left, right) => left.ref.localeCompare(right.ref) || left.source.localeCompare(right.source));
}

function commandError(command: string[], message: string, exitCode: number | null, stderr: string): DockerCleanupInventory["collection"]["errors"][number] {
  return { command, message, exitCode, stderrTail: stderr.slice(-1200) };
}

function dockerImagePresent(imageId: string): boolean | null {
  const inspect = runCommand(["docker", "image", "inspect", imageId], repoRoot, { timeoutMs: 15_000 });
  if (inspect.exitCode === 0) return true;
  const text = `${inspect.stdout}\n${inspect.stderr}`;
  if (/no such image|no such object/i.test(text)) return false;
  return null;
}

function rootDiskSnapshot(): DiskSnapshot | null {
  const result = runCommand(["df", "-B1", "-P", "/"], repoRoot, { timeoutMs: 5000 });
  if (result.exitCode !== 0) return null;
  const line = result.stdout.trim().split(/\r?\n/u)[1];
  if (!line) return null;
  const parts = line.trim().split(/\s+/u);
  if (parts.length < 6) return null;
  return {
    filesystem: parts[0] ?? "",
    sizeBytes: Number(parts[1]),
    usedBytes: Number(parts[2]),
    availableBytes: Number(parts[3]),
    usePercent: Number((parts[4] ?? "0").replace("%", "")),
    mount: parts[5] ?? "/",
  };
}

function tailText(value: string, maxChars: number): string {
  return value.length <= maxChars ? value : value.slice(-maxChars);
}

function shortContainerId(id: string): string {
  return id.slice(0, 12);
}

function shellQuote(value: string): string {
  if (/^[A-Za-z0-9_./:@=-]+$/u.test(value)) return value;
  return `'${value.replace(/'/g, `'\\''`)}'`;
}
