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

// Moved mechanically from scripts/src/deploy.ts:213-535 for #903.

import { createHash } from "node:crypto";
import { existsSync, mkdirSync, readFileSync } from "node:fs";
import { join, resolve } from "node:path";
import { pathToFileURL } from "node:url";
import { runCommand } from "../command";
import { type UniDeskConfig, type UniDeskMicroserviceConfig, repoRoot, rootPath } from "../config";
import { ensureGithubSshIdentityForProvider } from "../deploy-ssh-identity";
import { baiduNetdiskRuntimeSecretRequirements, runtimeSecretContractFromEnvText, type RuntimeSecretContract, runArtifactRegistryCommand } from "../artifact-registry";
import { startJob } from "../jobs";
import { coreInternalFetch } from "../microservices";
import { codeQueueSourceImportPreflight, codeQueueSourceSubdir } from "../code-queue-source-guard";
import { d601K3sGuardShellLines, d601NativeKubeconfig } from "../d601-k3s-guard";
import { composeRuntimeEnvValue } from "../runtime-env";
import {
  compareDeployJsonExecutorMirrors,
  deployJsonCommitImage,
  deployJsonDriftResult,
  deployJsonSourceOfTruth,
  encodeDeployJsonServiceContract,
  hasDeployJsonExecutorContract,
  k3sManifestExecutorMirror,
  parseDeployJsonServiceContract,
  type DeployJsonExecutorMirror,
  type DeployJsonServiceContract,
} from "../deploy-json-contract";
import { resolveArtifactRegistryTarget } from "../ops/targets";

import type { DeployEnvironment, DeployManifest, DeployManifestService, DeployOptions } from "./types";
import { step } from "./remote";
import { defaultDeployFile, defaultTimeoutMs, k8sKubeconfig, resolveFetchTimeout, unideskRepoUrl } from "./types";

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

export function deployHelp(action: string | undefined = undefined): Record<string, unknown> {
  const command = action === undefined || isHelpArg(action) ? "deploy check|plan|apply|guard" : `deploy ${action}`;
  return {
    ok: true,
    command,
    usage: {
      check: "bun scripts/cli.ts deploy check [--file deploy.json | --env dev|prod] [--service id]",
      plan: "bun scripts/cli.ts deploy plan [--file deploy.json | --env dev|prod] [--service id]",
      apply: "bun scripts/cli.ts deploy apply [--file deploy.json | --env dev|prod] [--service id] [--commit full-sha] [--dry-run] [--force] [--timeout-ms N] [--target D601|local] [--run-now]",
      guard: "bun scripts/cli.ts deploy guard code-queue-source [--root /home/ubuntu/cq-deploy]",
    },
    actions: {
      check: "Validate desired repo+commit state against live service health and commit markers.",
      plan: "Show desired/live drift, or with --env show the environment-ref dry-run plan without touching runtime resources.",
      apply: "Start an async deploy/reconcile job unless --run-now is explicitly present.",
      guard: "Run local deployment guards without mutating runtime resources.",
    },
    releaseV1Frontend: {
      producer: "bun scripts/cli.ts ci publish-user-service --service frontend --commit <release-v1-full-sha> --wait-ms 1200000",
      devConsumer: "bun scripts/cli.ts deploy apply --env dev --service frontend --commit <release-v1-full-sha>",
      prodConsumer: "bun scripts/cli.ts deploy apply --env prod --service frontend --commit <release-v1-full-sha>",
      boundary: "--commit selects an existing commit-pinned registry artifact for one reviewed artifact consumer; it does not read a dirty local manifest or make the server rebuild a release path.",
    },
    options: [
      { name: "--file <path>", default: defaultDeployFile, description: "Desired-state manifest path relative to the repo root. JSON and ESM JS manifests are supported, for example deploy.json or develop.js. Local D601 maintenance apply is limited to approved direct exceptions; Code Queue direct rollout is disabled." },
      { name: "--env <dev|prod>", description: "Read the named environment from origin/master:deploy.json. Dev apply supports reviewed artifact consumers for backend-core, frontend, baidu-netdisk, decision-center, mdtodo, claudeqq, dev-only code-queue, project-manager, oa-event-flow, code-queue-mgr, todo-note, findjob, pipeline, and met-nonlinear. Prod apply uses reviewed D601 registry artifact consumers; code-queue has no prod target and gated/incomplete services return structured unsupported or dry-run-only output." },
      { name: "--service <id>", description: "Limit reconcile to one service from the manifest." },
      { name: "--commit <full-sha>", description: "Artifact consumer override for one selected --env dev|prod service; the commit-pinned image must already exist in the D601 registry. This is the supported release/v1 frontend validation and rollback shape without editing a local manifest." },
      { name: "--dry-run", description: "Prepare and validate without mutating the target service." },
      { name: "--force", description: "Redeploy even when the live commit appears up to date." },
      { name: "--timeout-ms <n>", default: defaultTimeoutMs, description: "Per-step timeout budget where supported." },
      { name: "--target <id>", default: "config/artifact-registry.yaml#defaults.targetId", description: "Artifact-registry consumer target. --provider-id is accepted as a legacy alias." },
      { name: "--run-now", description: "Run apply in the foreground worker process; omit it for fire-and-forget async job mode." },
      { name: "guard code-queue-source --root <path>", description: "Validate Code Queue hostPath source relative imports before any scheduler rollout; failures report degradedReason and missing import targets." },
    ],
  };
}

export function nowIso(): string {
  return new Date().toISOString();
}

export function elapsedMs(startedAt: number): number {
  return Math.max(0, Date.now() - startedAt);
}

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

export function d601K3sGuardScript(): string {
  return d601K3sGuardShellLines(k8sKubeconfig).join("\n");
}

export function compactTail(text: string, maxChars = 1600): string {
  return text.length > maxChars ? text.slice(text.length - maxChars) : text;
}

export function progressLine(step: string, message: string, detail?: unknown): void {
  const payload = detail === undefined
    ? { at: nowIso(), step, message }
    : { at: nowIso(), step, message, detail };
  process.stderr.write(`${JSON.stringify(payload)}\n`);
}

export function asRecord(value: unknown): Record<string, unknown> | null {
  return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : null;
}

export function asString(value: unknown): string {
  return typeof value === "string" ? value : "";
}

export function parseJsonObjectFromText(text: string): unknown | null {
  const start = text.indexOf("{");
  if (start < 0) return null;
  let depth = 0;
  let inString = false;
  let escaped = false;
  for (let index = start; index < text.length; index += 1) {
    const char = text[index];
    if (inString) {
      if (escaped) {
        escaped = false;
      } else if (char === "\\") {
        escaped = true;
      } else if (char === "\"") {
        inString = false;
      }
      continue;
    }
    if (char === "\"") {
      inString = true;
      continue;
    }
    if (char === "{") {
      depth += 1;
      continue;
    }
    if (char === "}") {
      depth -= 1;
      if (depth === 0) return JSON.parse(text.slice(start, index + 1)) as unknown;
    }
  }
  return null;
}

export function parseFullCommit(value: string): string {
  const match = value.match(/\b[0-9a-f]{40}\b/iu);
  return match?.[0]?.toLowerCase() ?? "";
}

export function safeId(value: string): string {
  const sanitized = value.replace(/[^A-Za-z0-9_.-]/gu, "-");
  if (sanitized.length === 0) throw new Error(`invalid service id: ${value}`);
  return sanitized;
}

export function repoResolveCacheDir(repo: string): string {
  return rootPath(".state", "deploy", "resolve", createHash("sha256").update(repo).digest("hex").slice(0, 16));
}

export function repoSlug(repo: string): string | null {
  const trimmed = repo.trim().replace(/\.git$/u, "");
  const https = trimmed.match(/^https:\/\/([^/]+)\/(.+)$/u);
  if (https !== null) return `${https[1]?.toLowerCase()}/${https[2]}`;
  const ssh = trimmed.match(/^git@([^:]+):(.+)$/u);
  if (ssh !== null) return `${ssh[1]?.toLowerCase()}/${ssh[2]}`;
  return null;
}

export function isFullGitSha(value: string): boolean {
  return /^[0-9a-f]{40}$/iu.test(value);
}

export function isDeployEnvironment(value: string): value is DeployEnvironment {
  return value === "dev" || value === "prod";
}

export function isUnideskRepo(repo: string): boolean {
  const desiredSlug = repoSlug(repo);
  const unideskSlug = repoSlug(unideskRepoUrl);
  return desiredSlug !== null && desiredSlug === unideskSlug;
}

export function sshUrlForSlug(slug: string | null): string | null {
  if (slug === null) return null;
  const [host = "", ...pathParts] = slug.split("/");
  const path = pathParts.join("/");
  if (host.length === 0 || path.length === 0) return null;
  if (host !== "github.com" && host !== "gitee.com") return null;
  return `git@${host}:${path}.git`;
}

export function candidateRepoUrls(repo: string): string[] {
  const desiredSlug = repoSlug(repo);
  const urls = [repo];
  const localOrigin = runCommand(["git", "remote", "get-url", "origin"], repoRoot);
  const localOriginUrl = localOrigin.exitCode === 0 ? localOrigin.stdout.trim() : "";
  if (localOriginUrl.length > 0 && repoSlug(localOriginUrl) === desiredSlug) urls.push(localOriginUrl);
  const sshUrl = sshUrlForSlug(desiredSlug);
  if (sshUrl !== null) urls.push(sshUrl);
  return [...new Set(urls)];
}

export function providerSourceRepoUrl(repo: string): string {
  const slug = repoSlug(repo);
  if (slug === null || (!slug.startsWith("github.com/") && !slug.startsWith("gitee.com/"))) return repo;
  return sshUrlForSlug(slug) ?? repo;
}

export function localResolvedCommitForDesired(desired: DeployManifestService): string | null {
  const desiredSlug = repoSlug(desired.repo);
  if (desiredSlug === null || !isFullGitSha(desired.commitId)) return null;
  const localOrigin = runCommand(["git", "remote", "get-url", "origin"], repoRoot);
  const localOriginUrl = localOrigin.exitCode === 0 ? localOrigin.stdout.trim() : "";
  if (localOriginUrl.length === 0 || repoSlug(localOriginUrl) !== desiredSlug) return null;
  const resolved = runCommand(["git", "rev-parse", "--verify", `${desired.commitId}^{commit}`], repoRoot);
  const fullCommit = parseFullCommit(resolved.stdout);
  return resolved.exitCode === 0 && fullCommit === desired.commitId.toLowerCase() ? fullCommit : null;
}

export function commandFailure(result: ReturnType<typeof runCommand>, maxChars = 1200): string {
  const detail = [result.stderr, result.stdout].filter(Boolean).join("\n");
  return compactTail(detail, maxChars) || `exit ${result.exitCode}`;
}

export function runGitOrThrow(args: string[], cwd: string, message: string): ReturnType<typeof runCommand> {
  const result = runCommand(["git", ...args], cwd);
  if (result.exitCode !== 0) throw new Error(`${message}: ${commandFailure(result)}`);
  return result;
}

export function runResolveGit(args: string[], cwd: string): ReturnType<typeof runCommand> {
  return runCommand(["timeout", resolveFetchTimeout, "git", ...args], cwd);
}

export function resolveDesiredCommit(desired: DeployManifestService): DeployManifestService {
  const localCommit = localResolvedCommitForDesired(desired);
  if (localCommit !== null) return { ...desired, commitId: localCommit };
  if (isFullGitSha(desired.commitId) && !isUnideskRepo(desired.repo)) return { ...desired, commitId: desired.commitId.toLowerCase() };

  const cacheDir = repoResolveCacheDir(desired.repo);
  mkdirSync(cacheDir, { recursive: true });
  if (!existsSync(join(cacheDir, ".git", "config"))) {
    const init = runCommand(["git", "init", cacheDir], repoRoot);
    if (init.exitCode !== 0 && !existsSync(join(cacheDir, ".git", "config"))) {
      throw new Error(`failed to initialize deploy commit resolver for ${desired.repo}: ${commandFailure(init)}`);
    }
  }

  const fetchErrors: string[] = [];
  for (const repoUrl of candidateRepoUrls(desired.repo)) {
    runCommand(["git", "-C", cacheDir, "remote", "remove", "origin"], repoRoot);
    const addRemote = runCommand(["git", "-C", cacheDir, "remote", "add", "origin", repoUrl], repoRoot);
    if (addRemote.exitCode !== 0) {
      fetchErrors.push(`${repoUrl}: ${commandFailure(addRemote)}`);
      continue;
    }
    const fetches = desired.commitId.length === 40
      ? [
        runResolveGit(["-C", cacheDir, "fetch", "--no-tags", "--depth=1", "origin", desired.commitId], repoRoot),
        runResolveGit([
          "-C",
          cacheDir,
          "fetch",
          "--no-tags",
          "--prune",
          "origin",
          "+refs/heads/*:refs/remotes/origin/*",
          "+refs/tags/*:refs/tags/*",
        ], repoRoot),
      ]
      : [
        runResolveGit([
          "-C",
          cacheDir,
          "fetch",
          "--no-tags",
          "--prune",
          "origin",
          "+refs/heads/*:refs/remotes/origin/*",
          "+refs/tags/*:refs/tags/*",
        ], repoRoot),
      ];
    const fetch = fetches.find((result) => result.exitCode === 0) ?? fetches[fetches.length - 1];
    if (fetch.exitCode === 0) {
      fetchErrors.length = 0;
      break;
    }
    fetchErrors.push(`${repoUrl}: ${commandFailure(fetch)}`);
  }
  if (fetchErrors.length > 0) {
    throw new Error(`deploy manifest service ${desired.id} cannot fetch ${desired.repo} before deploy: ${compactTail(fetchErrors.join("\n"), 1600)}`);
  }

  const resolved = runCommand(["git", "-C", cacheDir, "rev-parse", "--verify", `${desired.commitId}^{commit}`], repoRoot);
  const fullCommit = parseFullCommit(resolved.stdout);
  if (resolved.exitCode !== 0 || fullCommit.length !== 40) {
    throw new Error(`deploy manifest service ${desired.id} commitId ${desired.commitId} cannot be resolved in ${desired.repo}; use a pushed unique 7-40 char SHA. ${commandFailure(resolved)}`);
  }
  return { ...desired, commitId: fullCommit };
}

export function resolveManifestCommits(manifest: DeployManifest, serviceId: string | null): DeployManifest {
  return {
    schemaVersion: manifest.schemaVersion,
    environment: manifest.environment,
    services: manifest.services.map((service) => (serviceId === null || service.id === serviceId ? resolveDesiredCommit(service) : service)),
  };
}

export function optionValue(args: string[], names: string[]): string | undefined {
  for (const name of names) {
    const index = args.indexOf(name);
    if (index === -1) continue;
    const raw = args[index + 1];
    if (raw === undefined || raw.length === 0 || raw.startsWith("--")) throw new Error(`${name} requires a non-empty value`);
    return raw;
  }
  return undefined;
}

export function positiveIntegerOption(args: string[], names: string[], defaultValue: number): number {
  const raw = optionValue(args, names);
  if (raw === undefined) return defaultValue;
  const parsed = Number(raw);
  if (!Number.isInteger(parsed) || parsed <= 0) throw new Error(`${names[0]} must be a positive integer`);
  return parsed;
}

export function environmentOption(args: string[]): DeployEnvironment | null {
  const raw = optionValue(args, ["--env", "--environment"]);
  if (raw === undefined) return null;
  if (!isDeployEnvironment(raw)) throw new Error("--env must be dev or prod");
  return raw;
}

export function parseOptions(args: string[]): DeployOptions {
  const environment = environmentOption(args);
  if (environment !== null && args.includes("--file")) throw new Error("deploy --env reads deploy.json from the fixed Git ref and cannot be combined with --file");
  const commitOverride = optionValue(args, ["--commit"]);
  const serviceId = optionValue(args, ["--service", "--service-id"]) ?? null;
  if (commitOverride !== undefined && environment === null) throw new Error("deploy --commit is only supported for --env dev|prod artifact consumer apply");
  if (commitOverride !== undefined && !isFullGitSha(commitOverride)) throw new Error("deploy --commit must be a full 40-character commit SHA");
  if (commitOverride !== undefined && serviceId === null) throw new Error("deploy --commit requires --service so artifact consumer apply is unambiguous");
  const artifactTarget = resolveArtifactRegistryTarget(optionValue(args, ["--target", "--provider-id", "--provider"]) ?? null);
  return {
    file: optionValue(args, ["--file"]) ?? defaultDeployFile,
    environment,
    serviceId,
    commitOverride: commitOverride?.toLowerCase() ?? null,
    providerId: artifactTarget.providerId,
    runNow: args.includes("--run-now"),
    dryRun: args.includes("--dry-run"),
    force: args.includes("--force"),
    timeoutMs: positiveIntegerOption(args, ["--timeout-ms"], defaultTimeoutMs),
  };
}
