// SPEC: PJ2026-01060308 cicd-yaml-targets draft-2026-06-25-cicd-yaml-targets.
import { createHash } from "node:crypto";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { basename, dirname, join, normalize, relative } from "node:path";
import pathPosix from "node:path/posix";
import type { UniDeskConfig } from "./config";
import { rootPath } from "./config";
import { coreInternalFetch } from "./microservices";
import { runSshCommandCapture, type SshCaptureResult } from "./ssh";

export interface OpsCommonOptions {
  targetId: string | null;
  full: boolean;
  raw: boolean;
}

export interface OpsApplyOptions extends OpsCommonOptions {
  confirm: boolean;
  dryRun: boolean;
  wait: boolean;
}

export interface OpsCommandOptionSpec {
  stringOptions?: string[];
  flagOptions?: string[];
}

export async function capture(config: UniDeskConfig, route: string, args: string[], stdin: string): Promise<SshCaptureResult> {
  return await runSshCommandCapture(config, route, args, stdin);
}

export function parseJsonOutput(stdout: string): Record<string, unknown> | null {
  const trimmed = stdout.trim();
  if (trimmed.length === 0) return null;
  const start = trimmed.indexOf("{");
  const end = trimmed.lastIndexOf("}");
  if (start === -1 || end === -1 || end <= start) return null;
  try {
    const parsed = JSON.parse(trimmed.slice(start, end + 1)) as unknown;
    return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? parsed as Record<string, unknown> : null;
  } catch {
    return null;
  }
}

export function compactCapture(result: SshCaptureResult, options: { full?: boolean } = {}): Record<string, unknown> {
  const full = options.full ?? false;
  return {
    exitCode: result.exitCode,
    stdoutBytes: Buffer.byteLength(result.stdout, "utf8"),
    stderrBytes: Buffer.byteLength(result.stderr, "utf8"),
    stdoutTail: full || result.exitCode !== 0 ? redactText(result.stdout).slice(-8000) : "",
    stderrTail: full || result.exitCode !== 0 ? redactText(result.stderr).slice(-4000) : "",
  };
}

export function redactText(text: string): string {
  return text
    .replace(/lbk_[A-Za-z0-9_-]+/gu, "lbk_<redacted>")
    .replace(/(postgres(?:ql)?:\/\/)[^@\s"']+@/giu, "$1<redacted>@")
    .replace(/(Bearer\s+)[A-Za-z0-9._~+/=-]+/giu, "$1<redacted>")
    .replace(/(["']?(?:N8N_ENCRYPTION_KEY|PASSWORD|SECRET|TOKEN|API[_-]?KEY|APIKEY|JWT[_-]?SECRET|DATABASE[_-]?URL)["']?\s*[:=]\s*["']?)[^"',\s}]+(["']?)/giu, "$1<redacted>$2");
}

export function fingerprintValues(values: Record<string, string>, keys: string[]): string {
  const hash = createHash("sha256");
  for (const key of keys.slice().sort()) {
    hash.update(key);
    hash.update("\0");
    hash.update(values[key] ?? "");
    hash.update("\0");
  }
  return `sha256:${hash.digest("hex")}`;
}

export function sha256Hex(value: string): string {
  return createHash("sha256").update(value).digest("hex");
}

export function sha256Fingerprint(value: string): string {
  return `sha256:${sha256Hex(value)}`;
}

export function shortSha256Fingerprint(value: string, chars = 12): string {
  return sha256Hex(value).slice(0, chars);
}

export function fingerprintEnvValues(values: Record<string, string>, keys: string[]): string {
  const material = keys
    .slice()
    .sort()
    .map((key) => `${key}=${values[key] ?? ""}`)
    .join("\n");
  return `sha256:${createHash("sha256").update(material).digest("hex")}`;
}

export function parseEnvFile(text: string): Record<string, string> {
  const result: Record<string, string> = {};
  for (const rawLine of text.split(/\r?\n/u)) {
    const line = rawLine.trim();
    if (line.length === 0 || line.startsWith("#")) continue;
    const eq = line.indexOf("=");
    if (eq <= 0) continue;
    const key = line.slice(0, eq).trim();
    if (!/^[A-Za-z_][A-Za-z0-9_]*$/u.test(key)) continue;
    result[key] = unquoteEnvValue(line.slice(eq + 1).trim());
  }
  return result;
}

function unquoteEnvValue(value: string): string {
  if ((value.startsWith("'") && value.endsWith("'")) || (value.startsWith("\"") && value.endsWith("\""))) return value.slice(1, -1);
  return value;
}

export function compactText(value: string, maxChars = 500): string {
  return value.replace(/\s+/gu, " ").trim().slice(0, maxChars);
}

export function shQuote(value: string): string {
  return `'${value.replaceAll("'", "'\"'\"'")}'`;
}

export function parseOpsCommonOptions(args: string[], spec: OpsCommandOptionSpec = {}): OpsCommonOptions & Record<string, string | boolean | null> {
  const stringOptions = new Set(["--target", ...(spec.stringOptions ?? [])]);
  const flagOptions = new Set(["--full", "--raw", ...(spec.flagOptions ?? [])]);
  const values: Record<string, string | boolean | null> = { full: false, raw: false };
  for (let index = 0; index < args.length; index += 1) {
    const arg = args[index];
    if (stringOptions.has(arg)) {
      const value = args[index + 1];
      if (value === undefined || value.startsWith("--")) throw new Error(`${arg} requires a value`);
      if (arg === "--target") values.targetId = value;
      else values[optionKey(arg)] = value;
      index += 1;
    } else if (flagOptions.has(arg)) {
      if (arg === "--full") values.full = true;
      else if (arg === "--raw") {
        values.raw = true;
        values.full = true;
      } else {
        values[optionKey(arg)] = true;
      }
    } else {
      throw new Error(`unsupported option: ${arg}`);
    }
  }
  const targetId = values.targetId === undefined ? null : String(values.targetId);
  if (targetId !== null && !/^[A-Za-z0-9._-]+$/u.test(targetId)) throw new Error("--target must be a simple target id");
  return { ...values, targetId } as OpsCommonOptions & Record<string, string | boolean | null>;
}

export function parseOpsApplyOptions(args: string[]): OpsApplyOptions {
  const commonArgs: string[] = [];
  let confirm = false;
  let dryRun = false;
  let wait = false;
  for (let index = 0; index < args.length; index += 1) {
    const arg = args[index];
    if (arg === "--confirm") confirm = true;
    else if (arg === "--dry-run") dryRun = true;
    else if (arg === "--wait") wait = true;
    else {
      commonArgs.push(arg);
      if (arg === "--target") {
        commonArgs.push(args[index + 1] ?? "");
        index += 1;
      }
    }
  }
  if (confirm && dryRun) throw new Error("apply accepts only one of --confirm or --dry-run");
  return { ...parseOpsCommonOptions(commonArgs), confirm, dryRun: dryRun || !confirm, wait };
}

export function readYamlRecord<T = Record<string, unknown>>(path: string, expectedKind?: string): T {
  const parsed = Bun.YAML.parse(readFileSync(path, "utf8")) as unknown;
  const record = asRecord(parsed, path);
  if (expectedKind !== undefined && record.kind !== expectedKind) throw new Error(`${repoRelative(path)}.kind must be ${expectedKind}`);
  return record as T;
}

export function asRecord(value: unknown, path: string): Record<string, unknown> {
  if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${path} must be an object`);
  return value as Record<string, unknown>;
}

export function recordField(obj: Record<string, unknown>, key: string, path: string): Record<string, unknown> {
  return asRecord(obj[key], `${path}.${key}`);
}

export function stringField(obj: Record<string, unknown>, key: string, path: string): string {
  const value = obj[key];
  if (typeof value !== "string" || value.trim().length === 0) throw new Error(`${path}.${key} must be a non-empty string`);
  return value.trim();
}

export function optionalStringField(obj: Record<string, unknown>, key: string, path: string): string | undefined {
  const value = obj[key];
  if (value === undefined || value === null) return undefined;
  if (typeof value !== "string" || value.trim().length === 0) throw new Error(`${path}.${key} must be a non-empty string when set`);
  return value.trim();
}

export function booleanField(obj: Record<string, unknown>, key: string, path: string): boolean {
  const value = obj[key];
  if (typeof value !== "boolean") throw new Error(`${path}.${key} must be a boolean`);
  return value;
}

export function numberField(obj: Record<string, unknown>, key: string, path: string): number {
  const value = obj[key];
  if (typeof value !== "number" || !Number.isFinite(value)) throw new Error(`${path}.${key} must be a finite number`);
  return value;
}

export function integerField(obj: Record<string, unknown>, key: string, path: string): number {
  const value = obj[key];
  if (!Number.isInteger(value)) throw new Error(`${path}.${key} must be an integer`);
  return Number(value);
}

export function optionalIntegerField(obj: Record<string, unknown>, key: string, path: string): number | undefined {
  const value = obj[key];
  if (value === undefined || value === null) return undefined;
  if (!Number.isInteger(value)) throw new Error(`${path}.${key} must be an integer when set`);
  return Number(value);
}

export function arrayField(obj: Record<string, unknown>, key: string, path: string): Record<string, unknown>[] {
  const value = obj[key];
  if (!Array.isArray(value)) throw new Error(`${path}.${key} must be an array`);
  return value.map((item, index) => asRecord(item, `${path}.${key}[${index}]`));
}

export function stringListField(obj: Record<string, unknown>, key: string, path: string): string[] {
  const value = obj[key];
  if (!Array.isArray(value) || value.some((item) => typeof item !== "string" || item.length === 0)) throw new Error(`${path}.${key} must be an array of non-empty strings`);
  return value as string[];
}

export function stringArrayField(obj: Record<string, unknown>, key: string, path: string): string[] {
  const value = obj[key];
  if (!Array.isArray(value)) throw new Error(`${path}.${key} must be an array`);
  return value.map((item, index) => {
    if (typeof item !== "string" || item.trim().length === 0) throw new Error(`${path}.${key}[${index}] must be a non-empty string`);
    return item.trim();
  });
}

export function yamlFieldLabel(configLabel: string, path: string, key: string): string {
  const prefix = path.length > 0 ? `${path}.` : "";
  return `${configLabel}.${prefix}${key}`;
}

export function yamlSubFieldLabel(label: string, key: string): string {
  return label.endsWith(".") ? `${label}${key}` : `${label}.${key}`;
}

export function yamlRecord(value: unknown, label: string): Record<string, unknown> {
  if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${label} must be a YAML object`);
  return value as Record<string, unknown>;
}

export function yamlObjectField(obj: Record<string, unknown>, key: string, configLabel: string, path: string): Record<string, unknown> {
  return yamlRecord(obj[key], yamlFieldLabel(configLabel, path, key));
}

export function yamlArrayOfRecords(value: unknown, configLabel: string, path: string): Record<string, unknown>[] {
  if (!Array.isArray(value)) throw new Error(`${configLabel}.${path} must be an array`);
  return value.map((item, index) => yamlRecord(item, `${configLabel}.${path}[${index}]`));
}

export function yamlStringField(obj: Record<string, unknown>, key: string, configLabel: string, path: string): string {
  const value = obj[key];
  if (typeof value !== "string" || value.trim().length === 0) throw new Error(`${yamlFieldLabel(configLabel, path, key)} must be a non-empty string`);
  return value.trim();
}

export function yamlIntegerField(obj: Record<string, unknown>, key: string, configLabel: string, path: string): number {
  const value = obj[key];
  if (typeof value !== "number" || !Number.isInteger(value)) throw new Error(`${yamlFieldLabel(configLabel, path, key)} must be an integer`);
  return value;
}

export function yamlBooleanField(obj: Record<string, unknown>, key: string, configLabel: string, path: string): boolean {
  const value = obj[key];
  if (typeof value !== "boolean") throw new Error(`${yamlFieldLabel(configLabel, path, key)} must be a boolean`);
  return value;
}

export function yamlStringArrayField(obj: Record<string, unknown>, key: string, configLabel: string, path: string): string[] {
  const value = obj[key];
  if (!Array.isArray(value) || value.some((item) => typeof item !== "string" || item.trim().length === 0)) throw new Error(`${yamlFieldLabel(configLabel, path, key)} must be a string array`);
  return value.map((item) => (item as string).trim());
}

export function yamlIntegerArrayField(obj: Record<string, unknown>, key: string, configLabel: string, path: string): number[] {
  const value = obj[key];
  if (!Array.isArray(value) || value.some((item) => typeof item !== "number" || !Number.isInteger(item))) throw new Error(`${yamlFieldLabel(configLabel, path, key)} must be an integer array`);
  return value as number[];
}

export function yamlEnumField<const T extends readonly string[]>(obj: Record<string, unknown>, key: string, configLabel: string, path: string, values: T): T[number] {
  const value = yamlStringField(obj, key, configLabel, path);
  if (!(values as readonly string[]).includes(value)) throw new Error(`${yamlFieldLabel(configLabel, path, key)} must be one of ${values.join(", ")}`);
  return value as T[number];
}

export function yamlKubernetesNameField(obj: Record<string, unknown>, key: string, configLabel: string, path: string): string {
  const value = yamlStringField(obj, key, configLabel, path);
  if (!/^[a-z0-9]([-a-z0-9]*[a-z0-9])?$/u.test(value)) throw new Error(`${yamlFieldLabel(configLabel, path, key)} must be a Kubernetes name`);
  return value;
}

export function yamlSourceRefField(obj: Record<string, unknown>, key: string, configLabel: string, path: string): string {
  const value = yamlStringField(obj, key, configLabel, path);
  if (value.startsWith("/") || value.includes("..") || !/^[A-Za-z0-9_./-]+$/u.test(value)) throw new Error(`${yamlFieldLabel(configLabel, path, key)} must be a relative source ref without ..`);
  return value;
}

export function yamlEnvKeyField(obj: Record<string, unknown>, key: string, configLabel: string, path: string): string {
  const value = yamlStringField(obj, key, configLabel, path);
  if (!/^[A-Z_][A-Z0-9_]*$/u.test(value)) throw new Error(`${yamlFieldLabel(configLabel, path, key)} must be an env key`);
  return value;
}

export function yamlPgIdentifierField(obj: Record<string, unknown>, key: string, configLabel: string, path: string): string {
  const value = yamlStringField(obj, key, configLabel, path);
  if (!/^[A-Za-z_][A-Za-z0-9_]*$/u.test(value)) throw new Error(`${yamlFieldLabel(configLabel, path, key)} must be a PostgreSQL identifier`);
  return value;
}

export function yamlHostField(obj: Record<string, unknown>, key: string, configLabel: string, path: string): string {
  const value = yamlStringField(obj, key, configLabel, path);
  if (!/^[A-Za-z0-9._:-]+$/u.test(value)) throw new Error(`${yamlFieldLabel(configLabel, path, key)} has an unsupported host format`);
  return value;
}

export function yamlPortField(obj: Record<string, unknown>, key: string, configLabel: string, path: string): number {
  const value = yamlIntegerField(obj, key, configLabel, path);
  if (value < 1 || value > 65535) throw new Error(`${yamlFieldLabel(configLabel, path, key)} must be a TCP port`);
  return value;
}

export function yamlAbsolutePathField(obj: Record<string, unknown>, key: string, configLabel: string, path: string): string {
  const value = yamlStringField(obj, key, configLabel, path);
  if (!value.startsWith("/")) throw new Error(`${yamlFieldLabel(configLabel, path, key)} must be absolute`);
  return value;
}

export function yamlApiPathField(obj: Record<string, unknown>, key: string, configLabel: string, path: string): string {
  const value = yamlStringField(obj, key, configLabel, path);
  if (!value.startsWith("/") || value.includes("..")) throw new Error(`${yamlFieldLabel(configLabel, path, key)} must be an absolute path without ..`);
  return value;
}

export function yamlHttpsUrlField(obj: Record<string, unknown>, key: string, configLabel: string, path: string): string {
  const value = yamlStringField(obj, key, configLabel, path);
  const url = new URL(value);
  if (url.protocol !== "https:" || url.search || url.hash) throw new Error(`${yamlFieldLabel(configLabel, path, key)} must be an https URL without query or hash`);
  return value.replace(/\/+$/u, "");
}

export interface YamlFieldReader {
  asRecord(value: unknown, label: string): Record<string, unknown>;
  objectField(obj: Record<string, unknown>, key: string, path: string): Record<string, unknown>;
  arrayOfRecords(value: unknown, path: string): Record<string, unknown>[];
  stringField(obj: Record<string, unknown>, key: string, path: string): string;
  integerField(obj: Record<string, unknown>, key: string, path: string): number;
  booleanField(obj: Record<string, unknown>, key: string, path: string): boolean;
  stringArrayField(obj: Record<string, unknown>, key: string, path: string): string[];
  numberArrayField(obj: Record<string, unknown>, key: string, path: string): number[];
  enumField<const T extends readonly string[]>(obj: Record<string, unknown>, key: string, path: string, values: T): T[number];
  kubernetesNameField(obj: Record<string, unknown>, key: string, path: string): string;
  sourceRefField(obj: Record<string, unknown>, key: string, path: string): string;
  envKeyField(obj: Record<string, unknown>, key: string, path: string): string;
  pgIdentifierField(obj: Record<string, unknown>, key: string, path: string): string;
  hostField(obj: Record<string, unknown>, key: string, path: string): string;
  portField(obj: Record<string, unknown>, key: string, path: string): number;
  absolutePathField(obj: Record<string, unknown>, key: string, path: string): string;
  apiPathField(obj: Record<string, unknown>, key: string, path: string): string;
  httpsUrlField(obj: Record<string, unknown>, key: string, path: string): string;
}

export function createYamlFieldReader(configLabel: string): YamlFieldReader {
  return {
    asRecord: (value, label) => yamlRecord(value, label),
    objectField: (obj, key, path) => yamlObjectField(obj, key, configLabel, path),
    arrayOfRecords: (value, path) => yamlArrayOfRecords(value, configLabel, path),
    stringField: (obj, key, path) => yamlStringField(obj, key, configLabel, path),
    integerField: (obj, key, path) => yamlIntegerField(obj, key, configLabel, path),
    booleanField: (obj, key, path) => yamlBooleanField(obj, key, configLabel, path),
    stringArrayField: (obj, key, path) => yamlStringArrayField(obj, key, configLabel, path),
    numberArrayField: (obj, key, path) => yamlIntegerArrayField(obj, key, configLabel, path),
    enumField: (obj, key, path, values) => yamlEnumField(obj, key, configLabel, path, values),
    kubernetesNameField: (obj, key, path) => yamlKubernetesNameField(obj, key, configLabel, path),
    sourceRefField: (obj, key, path) => yamlSourceRefField(obj, key, configLabel, path),
    envKeyField: (obj, key, path) => yamlEnvKeyField(obj, key, configLabel, path),
    pgIdentifierField: (obj, key, path) => yamlPgIdentifierField(obj, key, configLabel, path),
    hostField: (obj, key, path) => yamlHostField(obj, key, configLabel, path),
    portField: (obj, key, path) => yamlPortField(obj, key, configLabel, path),
    absolutePathField: (obj, key, path) => yamlAbsolutePathField(obj, key, configLabel, path),
    apiPathField: (obj, key, path) => yamlApiPathField(obj, key, configLabel, path),
    httpsUrlField: (obj, key, path) => yamlHttpsUrlField(obj, key, configLabel, path),
  };
}

export function repoRelative(path: string): string {
  const rel = relative(rootPath(), path);
  return rel.startsWith("..") ? path : rel;
}

export function resolveRepoPath(path: string): string {
  if (path.startsWith("/")) throw new Error(`repo-owned path must be relative: ${path}`);
  if (path.includes("..")) throw new Error(`repo-owned path must not contain ..: ${path}`);
  return rootPath(path);
}

export function sanitizePathSegment(value: string, fallback = "unknown"): string {
  const cleaned = value.trim().replace(/[^A-Za-z0-9._-]+/gu, "-").replace(/^-+|-+$/gu, "");
  return cleaned.length > 0 ? cleaned.slice(0, 120) : fallback;
}

export function dateInTimeZone(date: Date, timeZone: string): string {
  const parts = new Intl.DateTimeFormat("en-CA", {
    timeZone,
    year: "numeric",
    month: "2-digit",
    day: "2-digit",
  }).formatToParts(date);
  const pick = (type: string) => parts.find((part) => part.type === type)?.value ?? "00";
  return `${pick("year")}-${pick("month")}-${pick("day")}`;
}

export function sha256File(path: string): string {
  return createHash("sha256").update(readFileSync(path)).digest("hex");
}

export function ensureFileInside(root: string, relativePath: string): string {
  if (relativePath.startsWith("/") || relativePath.includes("..")) throw new Error(`path must be relative inside staging: ${relativePath}`);
  const rootAbsolute = hostRootPath(root);
  const resolved = normalize(join(rootAbsolute, relativePath));
  if (!resolved.startsWith(normalize(rootAbsolute + "/")) && resolved !== normalize(rootAbsolute)) throw new Error(`path escapes staging root: ${relativePath}`);
  mkdirSync(dirname(resolved), { recursive: true });
  return resolved;
}

export function writeStagingText(params: { hostRoot: string; relativePath: string; content: string }): { hostPath: string; bytes: number; sha256: string } {
  const hostPath = ensureFileInside(params.hostRoot, params.relativePath);
  writeFileSync(hostPath, params.content, "utf8");
  return { hostPath, bytes: Buffer.byteLength(params.content, "utf8"), sha256: sha256File(hostPath) };
}

export function writeStagingBase64(params: { hostRoot: string; relativePath: string; dataBase64: string }): { hostPath: string; bytes: number; sha256: string } {
  const buffer = Buffer.from(params.dataBase64, "base64");
  const hostPath = ensureFileInside(params.hostRoot, params.relativePath);
  writeFileSync(hostPath, buffer);
  return { hostPath, bytes: buffer.byteLength, sha256: sha256File(hostPath) };
}

export function containerPathToHostPath(containerRoot: string, hostRoot: string, containerPath: string): string | null {
  const cleanContainerRoot = normalize(containerRoot);
  const cleanPath = normalize(containerPath);
  if (cleanPath !== cleanContainerRoot && !cleanPath.startsWith(`${cleanContainerRoot}/`)) return null;
  const rel = relative(cleanContainerRoot, cleanPath);
  return join(hostRootPath(hostRoot), rel);
}

export function hostRootPath(root: string): string {
  return normalize(root.startsWith("/") ? root : rootPath(root));
}

export interface MicroserviceProxyResponse {
  ok: boolean;
  status: number | null;
  body: unknown;
  raw: Record<string, unknown> | null;
}

export function microserviceProxy(serviceId: string, upstreamPath: string, init: { method?: string; body?: unknown; timeoutMs?: number; maxResponseBytes?: number } = {}): MicroserviceProxyResponse {
  if (!upstreamPath.startsWith("/")) throw new Error("microservice upstream path must start with /");
  const response = coreInternalFetch(`/api/microservices/${encodeURIComponent(serviceId)}/proxy${upstreamPath}`, {
    method: init.method,
    body: init.body,
    timeoutMs: init.timeoutMs,
    maxResponseBytes: init.maxResponseBytes ?? 5_000_000,
  });
  const raw = typeof response === "object" && response !== null && !Array.isArray(response) ? response as Record<string, unknown> : null;
  return {
    ok: raw?.ok === true,
    status: typeof raw?.status === "number" ? raw.status : null,
    body: raw !== null && "body" in raw ? raw.body : response,
    raw,
  };
}

export function assertProxyOk(response: MicroserviceProxyResponse, context: string): Record<string, unknown> {
  if (response.ok && typeof response.body === "object" && response.body !== null && !Array.isArray(response.body)) return response.body as Record<string, unknown>;
  throw new Error(`${context} failed: ${JSON.stringify(compactProxyResponse(response)).slice(0, 1200)}`);
}

export function compactProxyResponse(response: MicroserviceProxyResponse): Record<string, unknown> {
  return {
    ok: response.ok,
    status: response.status,
    body: compactUnknown(response.body),
  };
}

export function compactUnknown(value: unknown, maxString = 400): unknown {
  if (typeof value === "string") return redactText(value).slice(0, maxString);
  if (Array.isArray(value)) return { arrayPreview: value.slice(0, 5).map((item) => compactUnknown(item, maxString)), count: value.length };
  if (typeof value !== "object" || value === null) return value;
  const record = value as Record<string, unknown>;
  const output: Record<string, unknown> = {};
  for (const key of Object.keys(record).slice(0, 20)) output[key] = redactSensitiveKey(key) ? "<redacted>" : compactUnknown(record[key], maxString);
  if (Object.keys(record).length > 20) output.omittedKeys = Object.keys(record).length - 20;
  return output;
}

export function redactSensitiveUnknown(value: unknown): unknown {
  if (typeof value === "string") return redactText(value);
  if (Array.isArray(value)) return value.map((item) => redactSensitiveUnknown(item));
  if (typeof value !== "object" || value === null) return value;
  const output: Record<string, unknown> = {};
  for (const [key, nested] of Object.entries(value as Record<string, unknown>)) output[key] = redactSensitiveKey(key) ? "<redacted>" : redactSensitiveUnknown(nested);
  return output;
}

export async function waitForBaiduTransfer(serviceId: string, jobId: string, options: { timeoutMs: number; pollIntervalMs: number }): Promise<Record<string, unknown>> {
  const deadline = Date.now() + options.timeoutMs;
  let last: Record<string, unknown> | null = null;
  while (Date.now() <= deadline) {
    const detail = assertProxyOk(microserviceProxy(serviceId, `/api/transfers/${encodeURIComponent(jobId)}`, { timeoutMs: 30_000 }), `baidu transfer ${jobId}`);
    const job = asRecord(detail.job, `transfer ${jobId}.job`);
    last = job;
    const status = String(job.status || "");
    if (status === "succeeded") return { ok: true, job, events: detail.events };
    if (status === "failed" || status === "canceled") return { ok: false, job, events: detail.events };
    await new Promise((resolve) => setTimeout(resolve, options.pollIntervalMs));
  }
  return { ok: false, timeout: true, job: last };
}

export function findBaiduFileByRemotePath(serviceId: string, remotePath: string): Record<string, unknown> | null {
  const dir = pathPosix.dirname(remotePath);
  const name = pathPosix.basename(remotePath);
  const response = assertProxyOk(microserviceProxy(serviceId, `/api/files?dir=${encodeURIComponent(dir)}&limit=500&order=time&desc=1`, { timeoutMs: 60_000 }), "baidu list files");
  const files = Array.isArray(response.files) ? response.files : [];
  for (const item of files) {
    if (typeof item !== "object" || item === null || Array.isArray(item)) continue;
    const record = item as Record<string, unknown>;
    if (String(record.path || "") === remotePath || String(record.serverFilename || record.filename || record.name || "") === name) return record;
  }
  return null;
}

export interface N8nWorkflowSyncResult {
  ok: boolean;
  mode: "dry-run" | "confirmed";
  workflow: { id: string; name: string; webhookPath: string; active: boolean };
  remote?: Record<string, unknown>;
  manifest?: Record<string, unknown>;
}

export async function syncN8nWorkflow(config: UniDeskConfig, params: {
  targetRoute: string;
  namespace: string;
  deploymentName: string;
  workflowJson: Record<string, unknown>;
  workflowId: string;
  workflowName: string;
  webhookPath: string;
  active: boolean;
  dryRun: boolean;
}): Promise<N8nWorkflowSyncResult> {
  if (params.dryRun) {
    return {
      ok: true,
      mode: "dry-run",
      workflow: { id: params.workflowId, name: params.workflowName, webhookPath: params.webhookPath, active: params.active },
      manifest: {
        nodes: Array.isArray(params.workflowJson.nodes) ? params.workflowJson.nodes.length : 0,
        active: params.workflowJson.active,
        settings: params.workflowJson.settings,
      },
    };
  }
  const encoded = Buffer.from(JSON.stringify(params.workflowJson, null, 2), "utf8").toString("base64");
  const script = `
set -u
tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' EXIT
workflow="$tmp/wechat-archive-workflow.json"
printf '%s' '${encoded}' | base64 -d >"$workflow"
kubectl -n ${params.namespace} exec -i deploy/${params.deploymentName} -c n8n -- sh -lc ${shQuote(`
set -u
mkdir -p /tmp/unidesk-n8n
cat >/tmp/unidesk-n8n/wechat-archive-workflow.json
n8n import:workflow --input=/tmp/unidesk-n8n/wechat-archive-workflow.json
`)} <"$workflow" >"$tmp/import.out" 2>"$tmp/import.err"
import_rc=$?
if [ "$import_rc" -eq 0 ] && [ "${params.active ? "1" : "0"}" = "1" ]; then
  kubectl -n ${params.namespace} exec deploy/${params.deploymentName} -c n8n -- n8n update:workflow --id ${shQuote(params.workflowId)} --active=true >"$tmp/activate.out" 2>"$tmp/activate.err"
  activate_rc=$?
else
  : >"$tmp/activate.out"
  if [ "$import_rc" -eq 0 ]; then
    printf '%s\\n' 'workflow activation not requested by YAML' >"$tmp/activate.err"
    activate_rc=0
  else
    printf '%s\\n' 'skipped because import failed' >"$tmp/activate.err"
    activate_rc=1
  fi
fi
if [ "$activate_rc" -eq 0 ] && [ "${params.active ? "1" : "0"}" = "1" ]; then
  kubectl -n ${params.namespace} rollout restart deployment/${params.deploymentName} >"$tmp/restart.out" 2>"$tmp/restart.err"
  restart_rc=$?
  if [ "$restart_rc" -eq 0 ]; then
    kubectl -n ${params.namespace} rollout status deployment/${params.deploymentName} --timeout=120s >"$tmp/rollout.out" 2>"$tmp/rollout.err"
    rollout_rc=$?
  else
    : >"$tmp/rollout.out"
    printf '%s\\n' 'skipped because rollout restart failed' >"$tmp/rollout.err"
    rollout_rc=1
  fi
else
  : >"$tmp/restart.out"
  : >"$tmp/rollout.out"
  printf '%s\\n' 'skipped because activation failed or workflow is inactive by YAML' >"$tmp/restart.err"
  printf '%s\\n' 'skipped because activation failed or workflow is inactive by YAML' >"$tmp/rollout.err"
  restart_rc=0
  rollout_rc=0
fi
kubectl -n ${params.namespace} exec deploy/${params.deploymentName} -c n8n -- n8n list:workflow >"$tmp/list.out" 2>"$tmp/list.err"
list_rc=$?
python3 - "$import_rc" "$activate_rc" "$restart_rc" "$rollout_rc" "$list_rc" "$tmp/import.out" "$tmp/import.err" "$tmp/activate.out" "$tmp/activate.err" "$tmp/restart.out" "$tmp/restart.err" "$tmp/rollout.out" "$tmp/rollout.err" "$tmp/list.out" "$tmp/list.err" <<'PY'
import json, sys
def text(path, limit=6000):
    try:
        return open(path, encoding="utf-8", errors="replace").read()[-limit:]
    except FileNotFoundError:
        return ""
payload = {
  "ok": int(sys.argv[1]) == 0 and int(sys.argv[2]) == 0 and int(sys.argv[3]) == 0 and int(sys.argv[4]) == 0,
  "workflow": {"id": "${params.workflowId}", "name": "${params.workflowName}", "webhookPath": "${params.webhookPath}", "active": ${params.active ? "True" : "False"}},
  "steps": {
    "import": {"exitCode": int(sys.argv[1]), "stdout": text(sys.argv[6]), "stderr": text(sys.argv[7])},
    "activate": {"exitCode": int(sys.argv[2]), "stdout": text(sys.argv[8]), "stderr": text(sys.argv[9])},
    "restart": {"exitCode": int(sys.argv[3]), "stdout": text(sys.argv[10]), "stderr": text(sys.argv[11])},
    "rollout": {"exitCode": int(sys.argv[4]), "stdout": text(sys.argv[12]), "stderr": text(sys.argv[13])},
    "list": {"exitCode": int(sys.argv[5]), "stdout": text(sys.argv[14]), "stderr": text(sys.argv[15])},
  },
}
print(json.dumps(payload, ensure_ascii=False, indent=2))
sys.exit(0 if payload["ok"] else 1)
PY
`;
  const result = await capture(config, params.targetRoute, ["sh"], script);
  const parsed = parseJsonOutput(result.stdout);
  return {
    ok: result.exitCode === 0 && parsed?.ok === true,
    mode: "confirmed",
    workflow: { id: params.workflowId, name: params.workflowName, webhookPath: params.webhookPath, active: params.active },
    remote: parsed ?? compactCapture(result, { full: true }),
  };
}

export async function fetchJsonWithTimeout(url: string, body: unknown, timeoutMs: number): Promise<Record<string, unknown>> {
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), timeoutMs);
  try {
    const response = await fetch(url, {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify(body),
      signal: controller.signal,
    });
    const text = await response.text();
    let parsed: unknown = text;
    try {
      parsed = text.length > 0 ? JSON.parse(text) as unknown : null;
    } catch {
      parsed = text;
    }
    return { ok: response.ok, status: response.status, body: parsed };
  } finally {
    clearTimeout(timer);
  }
}

export function renderTemplate(template: string, values: Record<string, string>): string {
  return template.replace(/\{\{\s*([A-Za-z0-9_]+)\s*\}\}/gu, (_match, key: string) => values[key] ?? "");
}

export function normalizeRemotePath(path: string): string {
  const normalized = pathPosix.normalize(path);
  if (!normalized.startsWith("/")) return `/${normalized}`;
  return normalized;
}

export function relativeStagingPath(dir: string, filename: string): string {
  return pathPosix.join(dir.replace(/^\/+|\/+$/gu, ""), basename(filename));
}

function optionKey(arg: string): string {
  return arg.replace(/^--/u, "").replace(/-([a-z])/gu, (_match, letter: string) => letter.toUpperCase());
}

function redactSensitiveKey(key: string): boolean {
  return /(?:password|secret|token|api[_-]?key|authorization|cookie|qrcode|usercode|verificationurl|dlink|thumbs)/iu.test(key);
}
