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

// Moved mechanically from scripts/src/platform-infra.ts:937-1587 for #903.

import { createHash, randomBytes } from "node:crypto";
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, isAbsolute, join } from "node:path";
import type { UniDeskConfig } from "../config";
import { rootPath } from "../config";
import { startJob } from "../jobs";
import type { RenderedCliResult } from "../output";
import { pk01CaddyMergeManagedBlocksPython, renderCaddyManagedBlock, renderSimpleReverseProxyCaddySiteBlock } from "../pk01-caddy";
import { capture, compactCapture, parseJsonOutput, prepareFrpcSecret, shQuote } from "../platform-infra-public-service";
import { yamlBooleanField, yamlFieldLabel, yamlIntegerField } from "../platform-infra-ops-library";
import { fingerprintSecretValues, parseEnvFile, readEnvSourceFile, readTextFile, redactRepoPath, requiredEnvValue } from "../secrets";

import type { ExternalDatabaseConfig, ManagedResourceCleanupPlan, Sub2ApiAccountLocalProxyConfig, Sub2ApiConfig, Sub2ApiEgressProxyConfig, Sub2ApiTargetConfig } from "./entry";
import { isKubernetesName, stringField } from "./config";
import { codexPoolConfigPath, configPath, manifestPath } from "./entry";

export function normalizePublicBaseUrl(value: string, path: string): string {
  let parsed: URL;
  try {
    parsed = new URL(value);
  } catch {
    throw new Error(`${configPath}.${path} must be a valid URL`);
  }
  parsed.pathname = parsed.pathname.replace(/\/+$/u, "");
  if (parsed.search || parsed.hash) throw new Error(`${configPath}.${path} must not include query or hash`);
  return parsed.toString().replace(/\/+$/u, "");
}

export function validateHostname(value: string, path: string): void {
  if (!/^[A-Za-z0-9.-]+$/u.test(value) || !value.includes(".")) throw new Error(`${configPath}.${path} must be a hostname`);
}

export function validateKubernetesResourceName(value: string, path: string): void {
  if (!isKubernetesName(value)) throw new Error(`${configPath}.${path} must be a Kubernetes resource name`);
}

export function validateHostOrIp(value: string, path: string): void {
  if (!/^[A-Za-z0-9._:-]+$/u.test(value)) throw new Error(`${configPath}.${path} has an unsupported format`);
}

export function validatePort(value: number, path: string): void {
  if (value < 1 || value > 65535) throw new Error(`${configPath}.${path} must be a TCP port`);
}

export function validateProxyName(value: string, path: string): void {
  if (!/^[A-Za-z0-9._-]+$/u.test(value)) throw new Error(`${configPath}.${path} has an unsupported format`);
}

export function validateProxyUrl(value: string, path: string): void {
  let url: URL;
  try {
    url = new URL(value);
  } catch {
    throw new Error(`${configPath}.${path} must be a valid proxy URL`);
  }
  if (url.protocol !== "http:" && url.protocol !== "https:") throw new Error(`${configPath}.${path} must use http:// or https://`);
  if (url.username || url.password || url.search || url.hash) throw new Error(`${configPath}.${path} must not include credentials, query, or hash`);
}

export function resolveTarget(sub2api: Sub2ApiConfig, targetId: string): Sub2ApiTargetConfig {
  const target = sub2api.targets.find((item) => item.id.toLowerCase() === targetId.toLowerCase());
  if (target === undefined) {
    const known = sub2api.targets.map((item) => item.id).join(", ");
    throw new Error(`unknown Sub2API target ${targetId}; known targets: ${known}`);
  }
  if (!target.enabled) throw new Error(`Sub2API target ${target.id} is disabled in ${configPath}`);
  return target;
}

export function targetImage(sub2api: Sub2ApiConfig, target: Sub2ApiTargetConfig): Sub2ApiConfig["image"] {
  return {
    repository: target.image.repository ?? sub2api.image.repository,
    tag: target.image.tag ?? sub2api.image.tag,
    pullPolicy: target.image.pullPolicy ?? sub2api.image.pullPolicy,
  };
}

export function targetDependencyImages(sub2api: Sub2ApiConfig, target: Sub2ApiTargetConfig): Sub2ApiConfig["dependencyImages"] {
  return {
    postgres: target.dependencyImages.postgres ?? sub2api.dependencyImages.postgres,
    redis: target.dependencyImages.redis ?? sub2api.dependencyImages.redis,
  };
}

export function imageRef(sub2api: Sub2ApiConfig, target: Sub2ApiTargetConfig): string {
  const image = targetImage(sub2api, target);
  return `${image.repository}:${image.tag}`;
}

export function isExternalTarget(target: Sub2ApiTargetConfig): boolean {
  return target.databaseMode === "external-pending" || target.databaseMode === "external-active";
}

export function isHostDockerTarget(target: Sub2ApiTargetConfig): boolean {
  return target.runtimeMode === "host-docker";
}

export function targetDatabase(sub2api: Sub2ApiConfig, target: Sub2ApiTargetConfig): ExternalDatabaseConfig {
  const hostDocker = target.hostDocker;
  if (hostDocker === null) return sub2api.runtime.database;
  return {
    ...sub2api.runtime.database,
    host: hostDocker.databaseHost,
    sslMode: hostDocker.databaseSslMode,
  };
}

export function manifest(sub2api: Sub2ApiConfig, target: Sub2ApiTargetConfig): string {
  if (isHostDockerTarget(target)) return "";
  if (isExternalTarget(target)) return externalPendingManifest(sub2api, target);
  const template = readFileSync(manifestPath, "utf8");
  const urlAllowlist = sub2api.security.urlAllowlist;
  const configHash = configHashFor(sub2api, target);
  const image = targetImage(sub2api, target);
  const dependencyImages = targetDependencyImages(sub2api, target);
  const proxyEnv = sub2ApiProxyEnv(target);
  return template
    .replaceAll("unidesk.ai/runtime-node: G14", `unidesk.ai/runtime-node: ${target.id}`)
    .replaceAll("__SUB2API_IMAGE__", imageRef(sub2api, target))
    .replaceAll("__SUB2API_IMAGE_PULL_POLICY__", image.pullPolicy)
    .replaceAll("postgres:18-alpine", dependencyImages.postgres)
    .replaceAll("redis:8-alpine", dependencyImages.redis)
    .replaceAll("__SUB2API_CONFIG_HASH__", configHash)
    .replaceAll("__SUB2API_SECURITY_URL_ALLOWLIST_ENABLED__", String(urlAllowlist.enabled))
    .replaceAll("__SUB2API_SECURITY_URL_ALLOWLIST_ALLOW_INSECURE_HTTP__", String(urlAllowlist.allowInsecureHttp))
    .replaceAll("__SUB2API_SECURITY_URL_ALLOWLIST_ALLOW_PRIVATE_HOSTS__", String(urlAllowlist.allowPrivateHosts))
    .replaceAll("__SUB2API_SECURITY_URL_ALLOWLIST_UPSTREAM_HOSTS__", urlAllowlist.upstreamHosts.join(","))
    .replaceAll(
      'UPDATE_PROXY_URL: ""',
      [
        `UPDATE_PROXY_URL: "${proxyEnv.httpProxy}"`,
        `  HTTP_PROXY: "${proxyEnv.httpProxy}"`,
        `  HTTPS_PROXY: "${proxyEnv.httpProxy}"`,
        `  ALL_PROXY: "${proxyEnv.httpProxy}"`,
        `  http_proxy: "${proxyEnv.httpProxy}"`,
        `  https_proxy: "${proxyEnv.httpProxy}"`,
        `  all_proxy: "${proxyEnv.httpProxy}"`,
        `  NO_PROXY: "${proxyEnv.noProxy}"`,
        `  no_proxy: "${proxyEnv.noProxy}"`,
      ].join("\n"),
    )
    .concat("\n", renderPublicExposureManifest(target), renderEgressProxyManifest(target));
}

export function configHashFor(sub2api: Sub2ApiConfig, target: Sub2ApiTargetConfig): string {
  return createHash("sha256")
    .update(JSON.stringify({
      image: targetImage(sub2api, target),
      dependencyImages: targetDependencyImages(sub2api, target),
      security: sub2api.security,
      target,
      runtime: sub2api.runtime,
    }))
    .digest("hex")
    .slice(0, 16);
}

export function targetHasSentinel(sub2api: Sub2ApiConfig, target: Sub2ApiTargetConfig): boolean {
  return sub2api.runtime.sentinel.enabledOnTargets.some((id) => id.toLowerCase() === target.id.toLowerCase());
}

export function managedResourceCleanupPlan(sub2api: Sub2ApiConfig, target: Sub2ApiTargetConfig): ManagedResourceCleanupPlan {
  const sentinel = codexPoolSentinelResourceNames();
  return {
    externalDbState: {
      enabled: isExternalTarget(target),
      ...sub2api.defaults.cleanup.externalDbState,
    },
    redisPersistentState: {
      enabled: target.redisMode === "local-ephemeral",
      ...sub2api.defaults.cleanup.redisPersistentState,
    },
    publicExposure: {
      enabled: target.publicExposure?.enabled === true && target.publicExposure.mode === "frp",
      deploymentName: target.publicExposure?.frpc?.deploymentName ?? sub2api.defaults.cleanup.publicExposure.deploymentName,
      configMapName: sub2api.defaults.cleanup.publicExposure.configMapName,
      secretName: target.publicExposure?.frpc?.secretName ?? sub2api.defaults.cleanup.publicExposure.secretName,
    },
    egressProxy: {
      enabled: target.egressProxy?.enabled === true,
      deploymentName: target.egressProxy?.deploymentName ?? sub2api.defaults.cleanup.egressProxy.deploymentName,
      serviceName: target.egressProxy?.serviceName ?? sub2api.defaults.cleanup.egressProxy.serviceName,
      secretName: target.egressProxy?.secretName ?? sub2api.defaults.cleanup.egressProxy.secretName,
    },
    sentinel: {
      ...sentinel,
      enabled: targetHasSentinel(sub2api, target),
    },
  };
}

export function codexPoolSentinelResourceNames(): ManagedResourceCleanupPlan["sentinel"] {
  if (!existsSync(codexPoolConfigPath)) throw new Error(`${codexPoolConfigPath} is required for Sub2API codex-pool sentinel cleanup resource names`);
  const parsed = Bun.YAML.parse(readFileSync(codexPoolConfigPath, "utf8")) as unknown;
  if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error(`${codexPoolConfigPath} must be an object`);
  const sentinel = (parsed as Record<string, unknown>).sentinel;
  if (typeof sentinel !== "object" || sentinel === null || Array.isArray(sentinel)) throw new Error(`${codexPoolConfigPath}.sentinel must be an object`);
  const record = sentinel as Record<string, unknown>;
  const resourceName = (key: string): string => {
    const value = stringField(record, key, "sentinel");
    validateKubernetesResourceName(value, `sentinel.${key}`);
    return value;
  };
  return {
    enabled: false,
    cronJobName: resourceName("cronJobName"),
    configMapName: resourceName("configMapName"),
    credentialsSecretName: resourceName("credentialsSecretName"),
    stateConfigMapName: resourceName("stateConfigMapName"),
    serviceAccountName: resourceName("serviceAccountName"),
    roleName: resourceName("roleName"),
    roleBindingName: resourceName("roleBindingName"),
  };
}

function renderRuntimeConfigInitContainer(
  target: Sub2ApiTargetConfig,
  database: ExternalDatabaseConfig,
  dependencyImages: Sub2ApiConfig["dependencyImages"],
): string {
  if (target.runtime.autoSetup) return "";
  return `        - name: write-runtime-config
          image: ${dependencyImages.postgres}
          imagePullPolicy: IfNotPresent
          command:
            - sh
            - -ec
            - |
              set -eu
              yaml_quote() {
                value=$(printf '%s' "$1" | sed "s/'/''/g")
                printf "'%s'" "$value"
              }
              mkdir -p /app/data
              {
                printf '%s\\n' 'server:'
                printf '    host: %s\\n' "$(yaml_quote "$SERVER_HOST")"
                printf '    port: %s\\n' "$SERVER_PORT"
                printf '    mode: %s\\n' "$(yaml_quote "$SERVER_MODE")"
                printf '%s\\n' 'database:'
                printf '    host: %s\\n' "$(yaml_quote "$DATABASE_HOST")"
                printf '    port: %s\\n' "$DATABASE_PORT"
                printf '    user: %s\\n' "$(yaml_quote "$DATABASE_USER")"
                printf '    password: %s\\n' "$(yaml_quote "$DATABASE_PASSWORD")"
                printf '    dbname: %s\\n' "$(yaml_quote "$DATABASE_DBNAME")"
                printf '    sslmode: %s\\n' "$(yaml_quote "$DATABASE_SSLMODE")"
                printf '%s\\n' 'redis:'
                printf '    host: %s\\n' "$(yaml_quote "$REDIS_HOST")"
                printf '    port: %s\\n' "$REDIS_PORT"
                printf '    password: %s\\n' "$(yaml_quote "$REDIS_PASSWORD")"
                printf '    db: %s\\n' "$REDIS_DB"
                printf '    enable_tls: %s\\n' "$REDIS_ENABLE_TLS"
                printf '%s\\n' 'jwt:'
                printf '    secret: %s\\n' "$(yaml_quote "$JWT_SECRET")"
                printf '    expire_hour: %s\\n' "$JWT_EXPIRE_HOUR"
                printf '%s\\n' 'default:'
                printf '%s\\n' '    user_concurrency: 5'
                printf '%s\\n' '    user_balance: 0'
                printf '%s\\n' "    api_key_prefix: 'sk-'"
                printf '%s\\n' '    rate_multiplier: 1'
                printf '%s\\n' 'rate_limit:'
                printf '%s\\n' '    requests_per_minute: 60'
                printf '%s\\n' '    burst_size: 10'
                printf 'timezone: %s\\n' "$(yaml_quote "$TZ")"
              } > /app/data/config.yaml
              chmod 600 /app/data/config.yaml
              chown -R 1000:1000 /app/data 2>/dev/null || true
          envFrom:
            - configMapRef:
                name: sub2api-config
          env:
            - name: DATABASE_PASSWORD
              valueFrom:
                secretKeyRef:
                  name: ${database.secretName}
                  key: ${database.passwordKey}
            - name: JWT_SECRET
              valueFrom:
                secretKeyRef:
                  name: ${database.secretName}
                  key: JWT_SECRET
          volumeMounts:
            - name: sub2api-data
              mountPath: /app/data
`;
}

export function externalPendingManifest(sub2api: Sub2ApiConfig, target: Sub2ApiTargetConfig): string {
  const urlAllowlist = sub2api.security.urlAllowlist;
  const database = targetDatabase(sub2api, target);
  const databaseStatus = target.databaseMode === "external-active" ? "external-db-active" : "pending-external-db";
  const redisService = sub2api.runtime.redis.serviceName;
  const appReplicas = target.appReplicas;
  const redisReplicas = target.redisReplicas;
  const image = targetImage(sub2api, target);
  const dependencyImages = targetDependencyImages(sub2api, target);
  const publicExposure = renderPublicExposureManifest(target);
  const egressProxy = renderEgressProxyManifest(target);
  const proxyEnv = sub2ApiProxyEnv(target);
  const runtimeConfigInitContainer = renderRuntimeConfigInitContainer(target, database, dependencyImages);
  const accountLocalProxyContainer = renderAccountLocalProxyContainer(target.accountLocalProxy);
  const accountLocalProxyVolume = renderAccountLocalProxyVolume(target.accountLocalProxy);
  const protection = target.resourceProtection;
  const priorityClass = protection === null ? "" : `apiVersion: scheduling.k8s.io/v1
kind: PriorityClass
metadata:
  name: ${protection.priorityClassName}
  labels:
    app.kubernetes.io/managed-by: unidesk
    app.kubernetes.io/part-of: platform-infra
value: ${protection.priorityValue}
globalDefault: false
preemptionPolicy: PreemptLowerPriority
description: UniDesk platform core workloads protected from batch CI pressure.
---
apiVersion: scheduling.k8s.io/v1
kind: PriorityClass
metadata:
  name: ${protection.batchPriorityClassName}
  labels:
    app.kubernetes.io/managed-by: unidesk
    app.kubernetes.io/part-of: platform-infra
value: ${protection.batchPriorityValue}
globalDefault: false
preemptionPolicy: Never
description: UniDesk batch CI yields resources to platform core workloads.
---
`;
  const priorityClassName = protection === null ? "" : `      priorityClassName: ${protection.priorityClassName}\n`;
  const redisResources = protection === null ? "" : renderContainerResources(protection.redis, 10);
  const appResources = protection === null ? "" : renderContainerResources(protection.app, 10);
  return `${priorityClass}apiVersion: v1
kind: Namespace
metadata:
  name: ${target.namespace}
  labels:
    app.kubernetes.io/name: platform-infra
    app.kubernetes.io/managed-by: unidesk
    unidesk.ai/runtime-node: ${target.id}
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: allow-all
  namespace: ${target.namespace}
  labels:
    app.kubernetes.io/name: platform-infra
    app.kubernetes.io/part-of: platform-infra
    app.kubernetes.io/managed-by: unidesk
spec:
  podSelector: {}
  policyTypes:
    - Ingress
    - Egress
  ingress:
    - {}
  egress:
    - {}
---
apiVersion: v1
kind: ConfigMap
metadata:
  name: sub2api-config
  namespace: ${target.namespace}
  labels:
    app.kubernetes.io/name: sub2api
    app.kubernetes.io/part-of: platform-infra
    app.kubernetes.io/managed-by: unidesk
    unidesk.ai/runtime-node: ${target.id}
data:
  AUTO_SETUP: "${target.runtime.autoSetup}"
  SERVER_HOST: "0.0.0.0"
  SERVER_PORT: "8080"
  SERVER_MODE: "release"
  RUN_MODE: "standard"
  DATABASE_HOST: "${database.host}"
  DATABASE_PORT: "${database.port}"
  DATABASE_USER: "${database.user}"
  DATABASE_DBNAME: "${database.dbName}"
  DATABASE_SSLMODE: "${database.sslMode}"
  DATABASE_MAX_OPEN_CONNS: "10"
  DATABASE_MAX_IDLE_CONNS: "2"
  DATABASE_CONN_MAX_LIFETIME_MINUTES: "30"
  DATABASE_CONN_MAX_IDLE_TIME_MINUTES: "5"
  REDIS_HOST: "${redisService}"
  REDIS_PORT: "6379"
  REDIS_PASSWORD: ""
  REDIS_DB: "0"
  REDIS_POOL_SIZE: "32"
  REDIS_MIN_IDLE_CONNS: "2"
  REDIS_ENABLE_TLS: "false"
  ADMIN_EMAIL: "admin@sub2api.platform-infra.local"
  JWT_EXPIRE_HOUR: "24"
  TZ: "Asia/Shanghai"
  SECURITY_URL_ALLOWLIST_ENABLED: "${urlAllowlist.enabled}"
  SECURITY_URL_ALLOWLIST_ALLOW_INSECURE_HTTP: "${urlAllowlist.allowInsecureHttp}"
  SECURITY_URL_ALLOWLIST_ALLOW_PRIVATE_HOSTS: "${urlAllowlist.allowPrivateHosts}"
  SECURITY_URL_ALLOWLIST_UPSTREAM_HOSTS: "${urlAllowlist.upstreamHosts.join(",")}"
  UPDATE_PROXY_URL: "${proxyEnv.httpProxy}"
  HTTP_PROXY: "${proxyEnv.httpProxy}"
  HTTPS_PROXY: "${proxyEnv.httpProxy}"
  ALL_PROXY: "${proxyEnv.httpProxy}"
  http_proxy: "${proxyEnv.httpProxy}"
  https_proxy: "${proxyEnv.httpProxy}"
  all_proxy: "${proxyEnv.httpProxy}"
  NO_PROXY: "${proxyEnv.noProxy}"
  no_proxy: "${proxyEnv.noProxy}"
  GATEWAY_OPENAI_RESPONSE_HEADER_TIMEOUT: "0"
  GATEWAY_OPENAI_HTTP2_ENABLED: "true"
  GATEWAY_OPENAI_HTTP2_ALLOW_PROXY_FALLBACK_TO_HTTP1: "true"
  GATEWAY_OPENAI_HTTP2_FALLBACK_ERROR_THRESHOLD: "2"
  GATEWAY_OPENAI_HTTP2_FALLBACK_WINDOW_SECONDS: "60"
  GATEWAY_OPENAI_HTTP2_FALLBACK_TTL_SECONDS: "600"
  GATEWAY_IMAGE_STREAM_DATA_INTERVAL_TIMEOUT: "900"
  GATEWAY_IMAGE_STREAM_KEEPALIVE_INTERVAL: "10"
  GATEWAY_IMAGE_CONCURRENCY_ENABLED: "false"
  GATEWAY_IMAGE_CONCURRENCY_MAX_CONCURRENT_REQUESTS: "0"
  GATEWAY_IMAGE_CONCURRENCY_OVERFLOW_MODE: "reject"
  GATEWAY_IMAGE_CONCURRENCY_WAIT_TIMEOUT_SECONDS: "30"
  GATEWAY_IMAGE_CONCURRENCY_MAX_WAITING_REQUESTS: "100"
---
apiVersion: v1
kind: Service
metadata:
  name: ${redisService}
  namespace: ${target.namespace}
  labels:
    app.kubernetes.io/name: sub2api-redis
    app.kubernetes.io/component: redis
    app.kubernetes.io/part-of: platform-infra
    app.kubernetes.io/managed-by: unidesk
    unidesk.ai/runtime-node: ${target.id}
    unidesk.ai/state-mode: ephemeral-cache
spec:
  selector:
    app.kubernetes.io/name: sub2api-redis
    app.kubernetes.io/component: redis
  ports:
    - name: redis
      port: 6379
      targetPort: redis
---
apiVersion: v1
kind: Service
metadata:
  name: sub2api
  namespace: ${target.namespace}
  labels:
    app.kubernetes.io/name: sub2api
    app.kubernetes.io/component: app
    app.kubernetes.io/part-of: platform-infra
    app.kubernetes.io/managed-by: unidesk
    unidesk.ai/runtime-node: ${target.id}
spec:
  selector:
    app.kubernetes.io/name: sub2api
    app.kubernetes.io/component: app
  ports:
    - name: http
      port: 8080
      targetPort: http
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: sub2api-redis
  namespace: ${target.namespace}
  labels:
    app.kubernetes.io/name: sub2api-redis
    app.kubernetes.io/component: redis
    app.kubernetes.io/part-of: platform-infra
    app.kubernetes.io/managed-by: unidesk
    unidesk.ai/runtime-node: ${target.id}
    unidesk.ai/state-mode: ephemeral-cache
spec:
  replicas: ${redisReplicas}
  strategy:
    type: Recreate
  selector:
    matchLabels:
      app.kubernetes.io/name: sub2api-redis
      app.kubernetes.io/component: redis
  template:
    metadata:
      labels:
        app.kubernetes.io/name: sub2api-redis
        app.kubernetes.io/component: redis
        app.kubernetes.io/part-of: platform-infra
    spec:
${priorityClassName}      securityContext:
        fsGroup: 999
      containers:
        - name: redis
          image: ${dependencyImages.redis}
          imagePullPolicy: IfNotPresent
${redisResources}          command:
            - sh
            - -c
          args:
            - redis-server --save "" --appendonly no
          ports:
            - name: redis
              containerPort: 6379
          env:
            - name: TZ
              value: Asia/Shanghai
          readinessProbe:
            exec:
              command:
                - redis-cli
                - ping
            initialDelaySeconds: 5
            periodSeconds: 10
            timeoutSeconds: 5
            failureThreshold: 6
          livenessProbe:
            exec:
              command:
                - redis-cli
                - ping
            initialDelaySeconds: 30
            periodSeconds: 20
            timeoutSeconds: 5
            failureThreshold: 6
          volumeMounts:
            - name: redis-data
              mountPath: /data
      volumes:
        - name: redis-data
          emptyDir: {}
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: sub2api
  namespace: ${target.namespace}
  labels:
    app.kubernetes.io/name: sub2api
    app.kubernetes.io/component: app
    app.kubernetes.io/part-of: platform-infra
    app.kubernetes.io/managed-by: unidesk
    unidesk.ai/runtime-node: ${target.id}
    unidesk.ai/database-status: ${databaseStatus}
spec:
  replicas: ${appReplicas}
  strategy:
    type: Recreate
  selector:
    matchLabels:
      app.kubernetes.io/name: sub2api
      app.kubernetes.io/component: app
  template:
    metadata:
      annotations:
        unidesk.ai/sub2api-config-hash: "${configHashFor(sub2api, target)}"
        unidesk.ai/database-source-ref: "${database.sourceRef}"
        unidesk.ai/database-status: "${databaseStatus}"
      labels:
        app.kubernetes.io/name: sub2api
        app.kubernetes.io/component: app
        app.kubernetes.io/part-of: platform-infra
    spec:
${priorityClassName}      securityContext:
        fsGroup: 1000
      initContainers:
${runtimeConfigInitContainer}
        - name: wait-postgres
          image: ${dependencyImages.postgres}
          imagePullPolicy: IfNotPresent
          command:
            - sh
            - -c
            - until (command -v pg_isready >/dev/null 2>&1 && pg_isready -h ${database.host} -p ${database.port} -U ${database.user} -d ${database.dbName}) || nc -z -w 2 ${database.host} ${database.port}; do sleep 2; done
        - name: wait-redis
          image: ${dependencyImages.redis}
          imagePullPolicy: IfNotPresent
          command:
            - sh
            - -c
            - until redis-cli -h ${redisService} ping | grep -q PONG; do sleep 2; done
      containers:
        - name: sub2api
          image: ${imageRef(sub2api, target)}
          imagePullPolicy: ${image.pullPolicy}
${appResources}          ports:
            - name: http
              containerPort: 8080
          envFrom:
            - configMapRef:
                name: sub2api-config
          env:
            - name: DATABASE_PASSWORD
              valueFrom:
                secretKeyRef:
                  name: ${database.secretName}
                  key: ${database.passwordKey}
            - name: ADMIN_PASSWORD
              valueFrom:
                secretKeyRef:
                  name: ${database.secretName}
                  key: ADMIN_PASSWORD
            - name: JWT_SECRET
              valueFrom:
                secretKeyRef:
                  name: ${database.secretName}
                  key: JWT_SECRET
            - name: TOTP_ENCRYPTION_KEY
              valueFrom:
                secretKeyRef:
                  name: ${database.secretName}
                  key: TOTP_ENCRYPTION_KEY
          readinessProbe:
            httpGet:
              path: /health
              port: http
            initialDelaySeconds: 10
            periodSeconds: 10
            timeoutSeconds: 5
            failureThreshold: 6
          livenessProbe:
            httpGet:
              path: /health
              port: http
            initialDelaySeconds: 30
            periodSeconds: 20
            timeoutSeconds: 5
            failureThreshold: 6
          startupProbe:
            httpGet:
              path: /health
              port: http
            periodSeconds: 10
            timeoutSeconds: 5
            failureThreshold: 30
          volumeMounts:
            - name: sub2api-data
              mountPath: /app/data
${accountLocalProxyContainer}
      volumes:
        - name: sub2api-data
          emptyDir: {}
${accountLocalProxyVolume}
${publicExposure}
${egressProxy}
`;
}

function renderContainerResources(resources: { requests: { cpu: string; memory: string }; limits: { cpu: string; memory: string } }, spaces: number): string {
  const indent = " ".repeat(spaces);
  return `${indent}resources:\n${indent}  requests:\n${indent}    cpu: ${resources.requests.cpu}\n${indent}    memory: ${resources.requests.memory}\n${indent}  limits:\n${indent}    cpu: ${resources.limits.cpu}\n${indent}    memory: ${resources.limits.memory}\n`;
}

export function sub2ApiProxyEnv(target: Sub2ApiTargetConfig): { httpProxy: string; noProxy: string } {
  const proxy = target.egressProxy;
  if (proxy === null || !proxy.enabled || !proxy.applyToSub2Api) return { httpProxy: "", noProxy: "" };
  return {
    httpProxy: `http://${proxy.serviceName}.${target.namespace}.svc.cluster.local:${proxy.listenPort}`,
    noProxy: proxy.noProxy.join(","),
  };
}

export function renderAccountLocalProxyContainer(proxy: Sub2ApiAccountLocalProxyConfig | null): string {
  if (proxy === null || !proxy.enabled) return "";
  return `        - name: ${proxy.containerName}
          image: ${proxy.image}
          imagePullPolicy: ${proxy.imagePullPolicy}
          args:
            - run
            - -c
            - /etc/sing-box/${proxy.secretKey}
          ports:
            - name: account-proxy
              containerPort: ${proxy.listenPort}
          volumeMounts:
            - name: account-local-proxy-config
              mountPath: /etc/sing-box/${proxy.secretKey}
              subPath: ${proxy.secretKey}
              readOnly: true
`;
}

export function renderAccountLocalProxyVolume(proxy: Sub2ApiAccountLocalProxyConfig | null): string {
  if (proxy === null || !proxy.enabled) return "";
  return `        - name: account-local-proxy-config
          secret:
            secretName: ${proxy.secretName}
`;
}

export function renderPublicExposureManifest(target: Sub2ApiTargetConfig): string {
  const exposure = target.publicExposure;
  if (exposure === null || !exposure.enabled) return "";
  if (exposure.mode === "pk01-local") return "";
  if (exposure.mode === "node-local-https") return "";
  if (exposure.frpc === null) throw new Error(`publicExposure.frpc is required for target ${target.id} when mode=frp`);
  return `---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: ${exposure.frpc.deploymentName}
  namespace: ${target.namespace}
  labels:
    app.kubernetes.io/name: ${exposure.frpc.deploymentName}
    app.kubernetes.io/component: tunnel
    app.kubernetes.io/part-of: platform-infra
    app.kubernetes.io/managed-by: unidesk
    unidesk.ai/runtime-node: ${target.id}
    unidesk.ai/public-hostname: ${exposure.dns.hostname}
spec:
  replicas: 1
  selector:
    matchLabels:
      app.kubernetes.io/name: ${exposure.frpc.deploymentName}
      app.kubernetes.io/component: tunnel
  template:
    metadata:
      labels:
        app.kubernetes.io/name: ${exposure.frpc.deploymentName}
        app.kubernetes.io/component: tunnel
        app.kubernetes.io/part-of: platform-infra
      annotations:
        unidesk.ai/public-base-url: "${exposure.publicBaseUrl}"
        unidesk.ai/frp-server: "${exposure.frpc.serverAddr}:${exposure.frpc.serverPort}"
        unidesk.ai/frp-remote-port: "${exposure.frpc.remotePort}"
    spec:
      containers:
        - name: frpc
          image: ${exposure.frpc.image}
          imagePullPolicy: IfNotPresent
          args:
            - -c
            - /etc/frp/frpc.toml
          volumeMounts:
            - name: frpc-config
              mountPath: /etc/frp/frpc.toml
              subPath: ${exposure.frpc.secretKey}
              readOnly: true
      volumes:
        - name: frpc-config
          secret:
            secretName: ${exposure.frpc.secretName}
`;
}

export function renderEgressProxyManifest(target: Sub2ApiTargetConfig): string {
  const proxy = target.egressProxy;
  if (proxy === null || !proxy.enabled) return "";
  const proxyConfigHash = egressProxyConfigHash(proxy);
  const hostNetworkSpec = proxy.hostNetwork
    ? `      hostNetwork: true
      dnsPolicy: ClusterFirstWithHostNet
`
    : "";
  return `---
apiVersion: v1
kind: Service
metadata:
  name: ${proxy.serviceName}
  namespace: ${target.namespace}
  labels:
    app.kubernetes.io/name: ${proxy.deploymentName}
    app.kubernetes.io/component: egress-proxy
    app.kubernetes.io/part-of: platform-infra
    app.kubernetes.io/managed-by: unidesk
    unidesk.ai/runtime-node: ${target.id}
    unidesk.ai/proxy-source: ${proxy.sourceType}
spec:
  selector:
    app.kubernetes.io/name: ${proxy.deploymentName}
    app.kubernetes.io/component: egress-proxy
  ports:
    - name: http
      port: ${proxy.listenPort}
      targetPort: proxy
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: ${proxy.deploymentName}
  namespace: ${target.namespace}
  labels:
    app.kubernetes.io/name: ${proxy.deploymentName}
    app.kubernetes.io/component: egress-proxy
    app.kubernetes.io/part-of: platform-infra
    app.kubernetes.io/managed-by: unidesk
    unidesk.ai/runtime-node: ${target.id}
    unidesk.ai/proxy-source: ${proxy.sourceType}
spec:
  replicas: 1
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 0
      maxUnavailable: 1
  selector:
    matchLabels:
      app.kubernetes.io/name: ${proxy.deploymentName}
      app.kubernetes.io/component: egress-proxy
  template:
    metadata:
      labels:
        app.kubernetes.io/name: ${proxy.deploymentName}
        app.kubernetes.io/component: egress-proxy
        app.kubernetes.io/part-of: platform-infra
      annotations:
        unidesk.ai/proxy-source-ref: "${proxy.sourceRef}"
        unidesk.ai/proxy-source-type: "${proxy.sourceType}"
        unidesk.ai/proxy-source-config-ref: "${proxy.sourceConfigRef ?? ""}"
        unidesk.ai/proxy-source-fingerprint: "${proxy.sourceFingerprint ?? ""}"
        unidesk.ai/proxy-selected-outbound: "${proxy.sourceType === "subscription-url" ? proxy.preferredOutbound : "shadowsocks"}"
        unidesk.ai/proxy-host-network: "${proxy.hostNetwork}"
        unidesk.ai/proxy-config-template: "${egressProxyConfigTemplateVersion}"
        unidesk.ai/proxy-config-hash: "${proxyConfigHash}"
    spec:
${hostNetworkSpec}      terminationGracePeriodSeconds: 10
      containers:
        - name: proxy
          image: ${proxy.image}
          imagePullPolicy: ${proxy.imagePullPolicy}
          args:
            - run
            - -c
            - /etc/sing-box/${proxy.secretKey}
          ports:
            - name: proxy
              containerPort: ${proxy.listenPort}
          readinessProbe:
            tcpSocket:
              port: proxy
            initialDelaySeconds: 5
            periodSeconds: 10
            timeoutSeconds: 5
            failureThreshold: 6
          livenessProbe:
            tcpSocket:
              port: proxy
            initialDelaySeconds: 30
            periodSeconds: 20
            timeoutSeconds: 5
            failureThreshold: 6
          volumeMounts:
            - name: proxy-config
              mountPath: /etc/sing-box/${proxy.secretKey}
              subPath: ${proxy.secretKey}
              readOnly: true
      volumes:
        - name: proxy-config
          secret:
            secretName: ${proxy.secretName}
`;
}

const egressProxyConfigTemplateVersion = "sing-box-clash-api-v1";

export function egressProxyConfigHash(proxy: Sub2ApiEgressProxyConfig): string {
  return createHash("sha256")
    .update(JSON.stringify({
      templateVersion: egressProxyConfigTemplateVersion,
      listenPort: proxy.listenPort,
      hostNetwork: proxy.hostNetwork,
      sourceType: proxy.sourceType,
      sourceConfigRef: proxy.sourceConfigRef,
      sourceRef: proxy.sourceRef,
      sourceFingerprint: proxy.sourceFingerprint,
      preferredOutbound: proxy.sourceType === "subscription-url" ? proxy.preferredOutbound : "shadowsocks",
      masterShadowsocks: proxy.masterShadowsocks,
    }))
    .digest("hex")
    .slice(0, 16);
}
