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

// Moved mechanically from scripts/src/ci.ts:268-591 for #903.

import { randomUUID } from "node:crypto";
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, posix as posixPath } from "node:path";
import { blockedCatalogArtifactIds, catalogSummary, findCiCatalogArtifact, loadCiCatalog, supportedSourceBuildArtifactIds, type CiCatalogArtifact, type CiSourceBuildCatalogArtifact, type CiUpstreamImageCatalogArtifact } from "../ci-catalog";
import { runCommand } from "../command";
import { type UniDeskConfig, repoRoot, rootPath } from "../config";
import { ensureGithubSshIdentityForProvider, gitSshHttpConnectProxySource } from "../deploy-ssh-identity";
import { jobWithTail, listJobs, readJob, startJob } from "../jobs";
import { coreInternalFetch } from "../microservices";
import {
  artifactRegistryReadonlyResultFromCommand,
  buildArtifactRegistryReadonlyProbe,
  parseArtifactRegistryOptions,
  type ArtifactRegistryReadonlyProbe,
} from "../artifact-registry";
import { d601K3sGuardShellLines, d601NativeKubeconfig } from "../d601-k3s-guard";
import { runSshCommandCapture } from "../ssh";

import type { CiCleanupFailedPodsOptions, CiCleanupRunsOptions, CiLogsOptions, CiPublishTransport, CiTarget } from "./types";
import { ciTarget, d601ProviderId, providerIdOption } from "./types";

export function stringOption(args: string[], name: string): string | null {
  const index = args.indexOf(name);
  if (index === -1) return null;
  const value = args[index + 1];
  if (value === undefined || value.startsWith("--")) throw new Error(`${name} requires a value`);
  return value;
}

export function numberOption(args: string[], name: string, fallback: number): number {
  const raw = stringOption(args, name);
  if (raw === null) return fallback;
  const value = Number(raw);
  if (!Number.isInteger(value) || value < 0) throw new Error(`${name} must be a non-negative integer`);
  return value;
}

export function ciLogsOptions(args: string[]): CiLogsOptions {
  const rawTail = stringOption(args, "--tail-lines") ?? stringOption(args, "--tail");
  const tailLines = rawTail === null ? 160 : Number(rawTail);
  if (!Number.isInteger(tailLines) || tailLines <= 0 || tailLines > 2000) {
    throw new Error("ci logs --tail-lines must be an integer between 1 and 2000");
  }
  return { tailLines, capture: args.includes("--dispatch-task") ? "dispatch-task" : "ssh-stream" };
}

export function ciCleanupRunsOptions(args: string[]): CiCleanupRunsOptions {
  const limit = numberOption(args, "--limit", 50);
  if (limit <= 0 || limit > 500) throw new Error("ci cleanup-runs --limit must be an integer between 1 and 500");
  const confirm = boolFlag(args, "--confirm");
  if (confirm && boolFlag(args, "--dry-run")) throw new Error("ci cleanup-runs accepts only one of --confirm or --dry-run");
  return {
    target: ciTarget(providerIdOption(args)),
    minAgeMinutes: numberOption(args, "--min-age-minutes", 60),
    limit,
    confirm,
  };
}

export function ciCleanupFailedPodsOptions(args: string[]): CiCleanupFailedPodsOptions {
  const limit = numberOption(args, "--limit", 50);
  if (limit <= 0 || limit > 500) throw new Error("ci cleanup-failed-pods --limit must be an integer between 1 and 500");
  const confirm = boolFlag(args, "--confirm");
  if (confirm && boolFlag(args, "--dry-run")) throw new Error("ci cleanup-failed-pods accepts only one of --confirm or --dry-run");
  return {
    target: ciTarget(providerIdOption(args)),
    namespace: requireK8sName(stringOption(args, "--namespace") ?? "unidesk-ci", "--namespace"),
    minAgeMinutes: numberOption(args, "--min-age-minutes", 60),
    limit,
    confirm,
  };
}

export function requireK8sName(value: string, option: string): string {
  if (!/^[a-z0-9]([-a-z0-9]{0,61}[a-z0-9])?$/u.test(value)) {
    throw new Error(`${option} must be a Kubernetes DNS label`);
  }
  return value;
}

export function countTextLines(value: string): number {
  if (value.length === 0) return 0;
  const breaks = value.match(/\r\n|\r|\n/gu)?.length ?? 0;
  return breaks + (/(\r\n|\r|\n)$/u.test(value) ? 0 : 1);
}

export function tailTextLines(value: string, limit: number): string {
  if (value.length === 0) return "";
  return value.split(/\r?\n/u).slice(-limit).join("\n");
}

export function boundedHintLine(line: string): string {
  const normalized = line.trim();
  if (normalized.length <= 1000) return normalized;
  return `${normalized.slice(0, 1000)}<truncated:${normalized.length - 1000}>`;
}

export function boundedHeadTail(value: string, headChars: number, tailChars: number): string {
  if (value.length <= headChars + tailChars + 80) return value;
  return `${value.slice(0, headChars)}\n...[omitted ${value.length - headChars - tailChars} chars]...\n${value.slice(-tailChars)}`;
}

export function extractCiLogConditionHints(value: string): string[] {
  return value
    .split(/\r?\n/u)
    .filter((line) => /^(condition=|taskrun=)/u.test(line.trim()))
    .map(boundedHintLine)
    .filter((line) => line.length > 0)
    .slice(-30);
}

export function extractCiLogFailureHints(value: string): string[] {
  const failurePattern = /\b(error|failed|failure|stepfailed|exit code|exited|denied|forbidden|unauthorized|timeout|timed out|panic|exception|could not|cannot|no such|killed|oom|back-off|imagepull|errimagepull|invalid|not found)\b/iu;
  return value
    .split(/\r?\n/u)
    .filter((line) => failurePattern.test(line))
    .map(boundedHintLine)
    .filter((line) => line.length > 0)
    .slice(-40);
}

export function requireRevision(value: string | null): string {
  if (value === null || value.length === 0) throw new Error("ci run requires --revision <commit-or-ref>");
  if (!/^[A-Za-z0-9._/@:-]{1,160}$/u.test(value) || value.startsWith("-") || value.includes("..")) throw new Error("ci --revision contains unsupported characters");
  return value;
}

export function requireFullCommit(value: string | null, option = "--commit"): string {
  const commit = value?.toLowerCase() ?? "";
  if (!/^[0-9a-f]{40}$/u.test(commit)) throw new Error(`${option} requires a full 40-character pushed Git commit SHA`);
  return commit;
}

export function requireServiceId(value: string | null): string {
  const serviceId = value ?? "";
  if (!/^[a-z0-9]([-a-z0-9]{0,62}[a-z0-9])?$/u.test(serviceId)) {
    throw new Error("ci publish-user-service requires --service <dns-safe-user-service-id>");
  }
  return serviceId;
}

export function requireDesiredRef(value: string | null): string {
  const ref = value ?? "master";
  if (!/^[A-Za-z0-9._/-]{1,160}$/u.test(ref) || ref.startsWith("-") || ref.includes("..")) {
    throw new Error("ci run-dev-e2e --desired-ref contains unsupported characters");
  }
  return ref;
}

export function boolFlag(args: string[], name: string): boolean {
  return args.includes(name);
}

export function publishTransportOption(value: string | null): CiPublishTransport {
  if (value === null || value === "auto") return "auto";
  if (value === "tekton" || value === "direct-docker") return value;
  throw new Error("ci publish-user-service --transport must be one of: auto, tekton, direct-docker");
}

export function isHelpArg(value: string | undefined): boolean {
  return value === "help" || value === "--help" || value === "-h";
}

export function shellQuote(value: string): string {
  return `'${value.replace(/'/gu, "'\\''")}'`;
}

export function ciTargetGuardShellLines(target: CiTarget, options: { passOutput?: "stdout" | "stderr" | "quiet" } = {}): string[] {
  if (target.providerId === d601ProviderId) {
    return d601K3sGuardShellLines(target.kubeconfig, options);
  }
  const passOutput = options.passOutput ?? "stdout";
  const labelSelector = target.requiredNodeLabel === undefined ? "" : `${target.requiredNodeLabel.key}=${target.requiredNodeLabel.value}`;
  const passLine = passOutput === "quiet"
    ? ":"
    : passOutput === "stderr"
      ? `printf '${target.guardName}=pass kubeconfig=%s context=%s server=%s nodes=%s\\n' "$required_kubeconfig" "$context" "$server" "$(printf '%s' "$nodes" | tr '\\n' ',')" >&2`
      : `printf '${target.guardName}=pass kubeconfig=%s context=%s server=%s nodes=%s\\n' "$required_kubeconfig" "$context" "$server" "$(printf '%s' "$nodes" | tr '\\n' ',')"`;
  return [
    `export KUBECONFIG=${shellQuote(target.kubeconfig)}`,
    "unidesk_ci_k3s_guard() {",
    `  required_kubeconfig=${shellQuote(target.kubeconfig)}`,
    `  required_node=${shellQuote(target.requiredNodeName ?? "")}`,
    `  required_selector=${shellQuote(labelSelector)}`,
    `  guard_name=${shellQuote(target.guardName)}`,
    "  if [ \"${KUBECONFIG:-}\" != \"$required_kubeconfig\" ]; then",
    "    printf '%s=blocked reason=wrong-kubeconfig expected=%s actual=%s\\n' \"$guard_name\" \"$required_kubeconfig\" \"${KUBECONFIG:-<unset>}\" >&2",
    "    return 1",
    "  fi",
    "  if ! command -v kubectl >/dev/null 2>&1; then",
    "    printf '%s=blocked reason=kubectl-missing\\n' \"$guard_name\" >&2",
    "    return 1",
    "  fi",
    "  if ! context=$(kubectl config current-context 2>&1); then",
    "    printf '%s=blocked reason=context-read-failed detail=%s\\n' \"$guard_name\" \"$context\" >&2",
    "    return 1",
    "  fi",
    "  if ! server=$(kubectl config view --minify -o 'jsonpath={.clusters[0].cluster.server}' 2>&1); then",
    "    printf '%s=blocked reason=server-read-failed detail=%s\\n' \"$guard_name\" \"$server\" >&2",
    "    return 1",
    "  fi",
    "  if ! nodes=$(kubectl get nodes -o 'jsonpath={range .items[*]}{.metadata.name}{\"\\n\"}{end}' 2>&1); then",
    "    printf '%s=blocked reason=nodes-read-failed detail=%s\\n' \"$guard_name\" \"$nodes\" >&2",
    "    return 1",
    "  fi",
    "  combined=$(printf '%s\\n%s\\n%s\\n' \"$context\" \"$server\" \"$nodes\")",
    "  if printf '%s\\n' \"$combined\" | grep -Eiq 'docker-desktop|desktop-control-plane|127\\.0\\.0\\.1:11700'; then",
    "    printf '%s=refused reason=forbidden-control-plane context=%s server=%s nodes=%s\\n' \"$guard_name\" \"$context\" \"$server\" \"$(printf '%s' \"$nodes\" | tr '\\n' ',')\" >&2",
    "    return 1",
    "  fi",
    "  if [ -n \"$required_node\" ] && ! printf '%s\\n' \"$nodes\" | grep -Fx \"$required_node\" >/dev/null; then",
    "    printf '%s=blocked reason=missing-required-node expected=%s nodes=%s\\n' \"$guard_name\" \"$required_node\" \"$(printf '%s' \"$nodes\" | tr '\\n' ',')\" >&2",
    "    return 1",
    "  fi",
    "  if [ -n \"$required_selector\" ]; then",
    "    if ! labeled_nodes=$(kubectl get nodes -l \"$required_selector\" -o 'jsonpath={range .items[*]}{.metadata.name}{\"\\n\"}{end}' 2>&1); then",
    "      printf '%s=blocked reason=label-query-failed selector=%s detail=%s\\n' \"$guard_name\" \"$required_selector\" \"$labeled_nodes\" >&2",
    "      return 1",
    "    fi",
    "    if [ -z \"$(printf '%s' \"$labeled_nodes\" | tr -d '\\n')\" ]; then",
    "      printf '%s=blocked reason=missing-required-label selector=%s nodes=%s\\n' \"$guard_name\" \"$required_selector\" \"$(printf '%s' \"$nodes\" | tr '\\n' ',')\" >&2",
    "      return 1",
    "    fi",
    "    nodes=\"$labeled_nodes\"",
    "  fi",
    `  ${passLine}`,
    "}",
    "unidesk_ci_k3s_guard",
  ];
}

export function safePathToken(value: string): string {
  return value.replace(/[^a-z0-9._-]/giu, "-").toLowerCase().replace(/^-+|-+$/gu, "").slice(0, 80) || "artifact";
}

export function repoSshUrl(repoUrl: string): string {
  if (repoUrl.startsWith("git@")) return repoUrl;
  if (repoUrl.startsWith("https://github.com/")) {
    const repoPath = repoUrl.slice("https://github.com/".length).replace(/\.git$/u, "");
    return `git@github.com:${repoPath}.git`;
  }
  return repoUrl;
}

export function repoNeedsGithubSshIdentity(repoFetchUrl: string): boolean {
  return repoFetchUrl.startsWith("git@github.com:");
}

export function repoConnectivityProbeUrl(repoFetchUrl: string): string {
  const sshMatch = /^git@([^:]+):/u.exec(repoFetchUrl);
  if (sshMatch !== null) return `https://${sshMatch[1]}`;
  try {
    const parsed = new URL(repoFetchUrl);
    return `${parsed.protocol}//${parsed.host}`;
  } catch {
    return repoFetchUrl.startsWith("https://gitee.com/") ? "https://gitee.com" : "https://github.com";
  }
}

export function requireRepoRelativePath(path: string, label: string): string {
  if (path.length === 0 || path.startsWith("/") || path.includes("\0") || path.split("/").includes("..")) {
    throw new Error(`${label} must be a repo-relative path`);
  }
  const normalized = posixPath.normalize(path);
  if (normalized === "." || normalized.startsWith("../")) throw new Error(`${label} must be a repo-relative path`);
  return normalized;
}

export function resolveCatalogArtifact(serviceId: string): CiCatalogArtifact {
  const artifact = findCiCatalogArtifact(serviceId);
  if (artifact === null) {
    const known = loadCiCatalog().artifacts.map((item) => item.serviceId).sort().join(", ");
    throw new Error(`unknown CI artifact service: ${serviceId}. Known services: ${known}`);
  }
  return artifact;
}

export function blockedArtifactResult(artifact: CiUpstreamImageCatalogArtifact | CiSourceBuildCatalogArtifact, commit: string, note: string): Record<string, unknown> {
  const base = {
    ok: false,
    status: "blocked",
    error: "blocked",
    serviceId: artifact.serviceId,
    commit,
    reason: note,
    catalogArtifact: artifact,
    boundary: "CI catalog marks this service as blocked; it must not be treated as a source-build artifact producer",
  };
  return artifact.kind === "upstream-image"
    ? {
      ...base,
      upstream: artifact.upstream,
      next: [
        `document the upstream image contract in CI.json for ${artifact.serviceId}`,
      ],
    }
    : {
      ...base,
      next: [
        `unblock ${artifact.serviceId} in CI.json before attempting source-build publication`,
      ],
    };
}

export function blockedReason(artifact: CiSourceBuildCatalogArtifact): string {
  if (artifact.blockedReason === undefined) throw new Error(`${artifact.serviceId} is blocked in CI.json but has no blockedReason`);
  return artifact.blockedReason;
}

export function userServicePublishBoundaryBlock(
  config: UniDeskConfig,
  serviceId: string,
  commit: string,
  artifact: CiSourceBuildCatalogArtifact,
): Record<string, unknown> | null {
  const configService = config.microservices.find((item) => item.id === serviceId);
  if (configService === undefined) return null;
  const isD601K3sService = configService.providerId === d601ProviderId
    && configService.development.providerId === d601ProviderId
    && configService.deployment.mode === "k3sctl-managed";
  const isD601DirectService = configService.providerId === d601ProviderId
    && configService.development.providerId === d601ProviderId
    && configService.deployment.mode === "unidesk-direct";
  const isMainServerDirectService = configService.providerId === "main-server"
    && configService.development.providerId === "main-server"
    && configService.deployment.mode === "unidesk-direct";
  const isMainServerInternalSidecar = configService.providerId === "main-server"
    && configService.development.providerId === "main-server"
    && configService.deployment.mode === "internal-sidecar";
  if (isD601K3sService || isD601DirectService || isMainServerDirectService || isMainServerInternalSidecar) return null;
  return {
    ok: false,
    status: "blocked",
    error: "blocked",
    serviceId,
    commit,
    reason: `config.json marks ${serviceId} as ${configService.providerId}/${configService.deployment.mode}, which is outside the reviewed CI artifact producer boundary`,
    catalogArtifact: artifact,
    configService: {
      providerId: configService.providerId,
      deploymentMode: configService.deployment.mode,
    },
  };
}
