#!/usr/bin/env node
// SPEC: PJ2026-01060505 Workbench Performance; PJ2026-0106 平台运维
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
import path from "node:path";

import {
  applyRuntimeLaneDeployConfig,
  argoApplicationName,
  artifactCatalogSkeleton,
  attachAccessControlEnv,
  annotate,
  cloneJson,
  defaultBranch,
  defaultCatalogPath,
  defaultGitopsBranch,
  defaultOutDir,
  defaultRuntimeEndpoint,
  defaultSourceRepo,
  defaultV02RuntimeEndpoint,
  defaultWebEndpoint,
  ensureObject,
  generatedPath,
  gitopsPathForProfile,
  imageTagForSource,
  isNamedRuntimeLane,
  isRuntimeLane,
  jsonManifest,
  label,
  laneRuntimeProfiles,
  namespaceNameForProfile,
  observabilityManifest,
  parseArgs,
  profileEndpoint,
  readJson,
  readJsonIfPresent,
  repoRoot,
  resolveSourceRevision,
  runtimeLabelForProfile,
  runtimePathForProfile,
  textFile,
  transformHealthContract,
  transformListNamespace,
  transformServices,
  transformWorkloads,
  usage,
  workbenchRuntimeRedisConfigForProfile
} from "./src/gitops-render/core.mjs";
import { registryManifest } from "./src/gitops-render/infra-manifests.mjs";
import {
  argoApplication,
  argoProject,
  deepSeekProxyManifest,
  deviceAgent71FreqManifest,
  externalPostgresConfigForLane,
  externalPostgresManifest,
  nodeFrpcManifest,
  opencodeServerManifest,
  runtimeConfigMapsForProfile,
  runtimeConfigMapsManifest,
  runtimePostgresImageForProfile,
  runtimeSecretPlaneConfigForProfile,
  runtimeSecretPlaneManifest,
  v02OpenFgaManifest,
  v02PostgresManifest,
  workbenchRuntimeRedisManifest
} from "./src/gitops-render/runtime-manifests.mjs";
import {
  ciLaneSettings,
  tektonControlPlaneReconcilerCronJob,
  tektonPipeline,
  tektonTasks,
  tektonPipelineRunTemplate,
  tektonPollerCronJob,
  tektonRbac
} from "./src/gitops-render/tekton-manifests.mjs";
import { CLOUD_CORE_MIGRATIONS } from "../internal/db/schema.ts";

function runtimeKustomization({ profile = "dev", includeDeviceAgent = profile === "dev", externalPostgres = false, workbenchRedis = false, runtimeConfigMaps = false, externalSecrets = false, deploy = null } = {}) {
  const resources = ["namespace.yaml", "services.yaml", "health-contract.yaml", "workloads.yaml", "deepseek-proxy.yaml"];
  if (!isRuntimeLane(profile)) resources.splice(1, 0, "code-agent-codex-config.yaml");
  if (runtimeConfigMaps) resources.splice(3, 0, "configmaps.yaml");
  if (externalSecrets) resources.splice(resources.indexOf("workloads.yaml"), 0, "external-secrets.yaml");
  if (isRuntimeLane(profile)) {
    if (externalPostgres) resources.push("external-postgres.yaml");
    else resources.push("postgres.yaml");
    if (workbenchRedis) resources.push("workbench-redis.yaml");
    resources.push("openfga.yaml", "observability.yaml", "opencode.yaml");
  }
  if (includeDeviceAgent) resources.push("device-agent-71-freq.yaml");
  resources.push("node-frpc.yaml");
  return {
    apiVersion: "kustomize.config.k8s.io/v1beta1",
    kind: "Kustomization",
    namespace: namespaceNameForProfile(profile, deploy),
    resources
  };
}

function readme({ source, args, deploy }) {
  if (args.lane === "v02") {
    return `# HWLAB v0.2 GitOps CI/CD

This directory is generated by \`scripts/gitops-render.mjs --lane v02\` from source branch \`${args.sourceBranch}\`.

- Target: additive node-scoped v0.2 lane only.
- CI: Tekton Pipeline \`hwlab-ci/hwlab-v02-ci-image-publish\`.
- Git mirror/relay: v0.2 CI reads source/catalog from \`${args.gitReadUrl}\` and writes GitOps promotion to \`${args.gitWriteUrl}\`; canonical repo \`${args.sourceRepo}\` remains the source identity and manual flush upstream.
- Artifact contract: runtime images come from \`${args.catalogPath}\`; source branch \`v0.2\` does not track this generated catalog.
- Manual trigger: run \`bun scripts/cli.ts hwlab node control-plane trigger-current --lane v02 --confirm\` from UniDesk to create commit-pinned PipelineRun \`hwlab-v02-ci-poll-<short12>\` for the current \`origin/v0.2\` commit.
- Branch split: source branch \`${args.sourceBranch}\` remains human-authored source; generated desired state is promoted to local mirror branch \`${args.gitopsBranch}\`.
- CD: Argo CD Application \`argocd/hwlab-node-v02\` consumes \`${args.gitReadUrl}:${args.gitopsBranch}:deploy/gitops/node/runtime-v02\` and deploys only to namespace \`hwlab-v02\`.
- Public preview: FRP exposes v0.2 web \`${args.webEndpoint}\` / edge \`${args.runtimeEndpoint}\` through \`hwlab-v02/hwlab-v02-frpc\`.
- node DEV/PROD boundary: this lane does not modify archived DEV/PROD branches, \`hwlab-dev\`, \`hwlab-prod\`, 17666/17667 or 18666/18667.
- CronJob policy: v0.2 does not generate \`hwlab-v02-branch-poller\` or \`hwlab-v02-control-plane-reconciler\`; obsolete live CronJobs should be deleted by the UniDesk control-plane apply command.
- Gate policy: old DEV/D601/main gates do not enter this lane; new checks stay limited to the fixed branch, namespace, catalog, runtime path and Argo boundaries.
`;
  }
  if (isNamedRuntimeLane(args.lane)) {
    const namespace = namespaceNameForProfile(args.lane, deploy);
    const runtimePath = gitopsPathForProfile(args, args.lane, deploy);
    return `# HWLAB ${args.lane} GitOps CI/CD

This directory is generated by \`scripts/gitops-render.mjs --lane ${args.lane}\` from the owning runtime lane declaration in \`deploy/deploy.yaml\`.

- Source: \`${args.sourceRepo}\` branch \`${args.sourceBranch}\` at \`${source.full}\`.
- GitOps: generated desired state is promoted to \`${args.gitWriteUrl}\` branch \`${args.gitopsBranch}\`.
- Artifact contract: runtime images come from \`${args.catalogPath}\`.
- Runtime: Argo CD consumes \`${args.gitReadUrl}:${args.gitopsBranch}:${runtimePath}\` and deploys only to namespace \`${namespace}\`.
- Public endpoints: web \`${args.webEndpoint}\`; API \`${args.runtimeEndpoint}\`.
- Authority: branch, catalog, runtime path, namespace, public services and endpoints are read from \`deploy/deploy.yaml\`; missing named-lane fields fail closed.
`;
  }
  return `# HWLAB node GitOps CI/CD

This directory is generated from built-in Tekton-native CI primitive declarations plus human-authored \`deploy/deploy.yaml\`, CI-authored \`deploy/artifact-catalog.dev.json\`, and \`deploy/k8s/*\` by \`scripts/gitops-render.mjs\`.

- Target: node k3s only.
- CI: Tekton Pipeline \`hwlab-ci/hwlab-node-ci-image-publish\`.
- Artifact contract: runtime images come from \`deploy/artifact-catalog.dev.json\`; \`deploy/deploy.yaml\` only carries human-authored config such as env and topology.
- Branch split: source branch \`${args.sourceBranch}\` remains the watched code branch; generated desired state is promoted to \`${args.gitopsBranch}\`.
- CD: Argo CD Applications \`argocd/hwlab-node-dev\` and \`argocd/hwlab-node-prod\` consume \`${args.gitopsBranch}:deploy/gitops/node/runtime-dev\` and \`runtime-prod\`.
- Public preview: FRP exposes node DEV web \`${args.webEndpoint}\` / edge \`${args.runtimeEndpoint}\` through \`hwlab-dev/hwlab-node-frpc\`, and node PROD web \`${args.prodWebEndpoint}\` / edge \`${args.prodRuntimeEndpoint}\` through \`hwlab-prod/hwlab-node-prod-frpc\`; D601 keeps \`:16666/:16667\`.
- Manual polling: run \`kubectl -n hwlab-ci create job --from=cronjob/hwlab-node-branch-poller hwlab-node-branch-poller-manual-$(date -u +%Y%m%d%H%M%S)\`; this reuses the same poller path and creates a commit-pinned Tekton PipelineRun only when needed.
- Control-plane reconcile: CronJob \`hwlab-ci/hwlab-node-control-plane-reconciler\` polls \`${args.sourceBranch}\`, runs \`scripts/gitops-render.mjs\`, and server-side-applies the generated Tekton RBAC/Pipeline/Poller/Reconciler manifests so CI control-plane changes do not require a manual render/apply step.
- D601 boundary: this path does not target D601, does not use UniDesk Code Queue, and does not change D601 production traffic.
- Concurrency: CI builds are per source commit and do not acquire the legacy DEV CD Lease; Argo CD reconciles the Git desired state.
`;
}

async function plannedFiles(args) {
  const [deploy, namespaceTemplate, config, services, health, workloads] = await Promise.all([
    readJson("deploy/deploy.yaml"),
    readJson("deploy/k8s/base/namespace.yaml"),
    readJson("deploy/k8s/base/code-agent-codex-config.yaml"),
    readJson("deploy/k8s/base/services.yaml"),
    readJson("deploy/k8s/dev/health-contract.yaml"),
    readJson("deploy/k8s/base/workloads.yaml")
  ]);
  args = applyRuntimeLaneDeployConfig(args, deploy, {
    defaultBranch,
    defaultGitopsBranch,
    defaultCatalogPath,
    defaultGitopsRoot: defaultOutDir,
    defaultRuntimeEndpoint,
    defaultWebEndpoint,
    defaultV02RuntimeEndpoint,
    defaultSourceRepo
  });
  await attachAccessControlEnv(deploy);
  const source = await resolveSourceRevision(args.sourceRevision);
  source.imageTag = imageTagForSource(args, source);
  let artifactCatalog = await readJsonIfPresent(args.catalogPath, null);
  if (!artifactCatalog && isRuntimeLane(args.lane)) artifactCatalog = artifactCatalogSkeleton({ args, deploy, source });
  if (!artifactCatalog) artifactCatalog = await readJson(args.catalogPath);
  const settings = ciLaneSettings(args, deploy);
  const profiles = laneRuntimeProfiles(args);
  const migrationSources = isRuntimeLane(args.lane)
    ? await Promise.all(CLOUD_CORE_MIGRATIONS.map(async (migration) => ({
        id: migration.id,
        path: migration.path,
        sql: await readFile(path.join(repoRoot, migration.path), "utf8")
      })))
    : [];
  const metricsSidecarScript = isRuntimeLane(args.lane)
    ? await readFile(path.join(repoRoot, "internal/dev-entrypoint/metrics-sidecar.mjs"), "utf8")
    : null;
  const metricsSidecarSha256 = metricsSidecarScript ? createHash("sha256").update(metricsSidecarScript).digest("hex") : null;
  const baseLabels = { "hwlab.pikastech.local/gitops-target": settings.gitopsTarget, "hwlab.pikastech.local/source-commit": source.full };
  const annotations = { "hwlab.pikastech.local/rendered-by": "scripts/gitops-render.mjs" };
  const files = new Map();
  const putJson = (relative, value) => files.set(path.join(args.outDir, relative), jsonManifest(value));
  const putText = (relative, value) => files.set(path.join(args.outDir, relative), textFile(value));
  const publicEndpoints = Object.fromEntries(profiles.map((profile) => {
    const endpoints = profileEndpoint(args, profile);
    return [profile, { web: endpoints.webEndpoint, runtime: endpoints.runtimeEndpoint }];
  }));

  const sourceDescriptor = {
    kind: isRuntimeLane(args.lane) ? `hwlab-${args.lane}-gitops-source` : "hwlab-node-gitops-source",
    sourceCommit: source.full,
    sourceShortCommit: source.short,
    sourceImageTag: source.imageTag,
    sourceBranch: args.sourceBranch,
    gitopsBranch: args.gitopsBranch,
    lane: settings.lane,
    sourceRepo: args.sourceRepo,
    gitReadUrl: args.gitReadUrl,
    gitWriteUrl: args.gitWriteUrl,
    registryPrefix: args.registryPrefix,
    runtimePaths: profiles.map((profile) => gitopsPathForProfile(args, profile, deploy)),
    publicEndpoints,
    frpDeployments: Object.fromEntries(profiles.map((profile) => [profile, `${namespaceNameForProfile(profile, deploy)}/${isRuntimeLane(profile) ? `hwlab-${profile}-frpc` : profile === "prod" ? "hwlab-node-prod-frpc" : "hwlab-node-frpc"}`])),
    tektonPipeline: `hwlab-ci/${settings.pipelineName}`,
    argoApplications: profiles.map((profile) => `argocd/${argoApplicationName(profile)}`)
  };
  if (args.useDeployImages) {
    sourceDescriptor.renderMode = {
      imageSource: args.catalogPath,
      configSource: "deploy/deploy.yaml",
      mixedDesiredState: true
    };
  }
  putJson("source.json", sourceDescriptor);
  putText("README.md", readme({ source, args, deploy }));
  putJson("registry/registry.yaml", registryManifest());
  putJson(`${settings.tektonDir}/rbac.yaml`, tektonRbac(args, deploy));
  putJson(`${settings.tektonDir}/tasks.yaml`, { apiVersion: "v1", kind: "List", items: tektonTasks(args, deploy) });
  putJson(`${settings.tektonDir}/pipeline.yaml`, tektonPipeline(args, deploy));
  if (!isRuntimeLane(args.lane)) {
    putJson(`${settings.tektonDir}/poller.yaml`, tektonPollerCronJob(args, deploy));
    putJson(`${settings.tektonDir}/control-plane-reconciler.yaml`, tektonControlPlaneReconcilerCronJob(args));
  }
  putJson(`${settings.tektonDir}/pipelinerun.sample.yaml`, tektonPipelineRunTemplate({ source, args, deploy }));
  putJson("argocd/project.yaml", argoProject(args, deploy));
  for (const profile of profiles) putJson(`argocd/application-${profile}.yaml`, argoApplication(args, profile, deploy));

  for (const profile of profiles) {
    const namespace = namespaceNameForProfile(profile, deploy);
    const includeDeviceAgent = profile === "dev";
    const externalPostgres = externalPostgresConfigForLane(deploy, profile, args);
    const workbenchRedis = workbenchRuntimeRedisConfigForProfile(deploy, profile);
    const runtimeConfigMaps = runtimeConfigMapsForProfile(deploy, profile);
    const runtimeSecretPlane = await runtimeSecretPlaneConfigForProfile(deploy, profile, args);
    const profileLabels = { ...baseLabels, "hwlab.pikastech.local/environment": runtimeLabelForProfile(profile), "hwlab.pikastech.local/profile": runtimeLabelForProfile(profile) };
    const runtimeNamespace = cloneJson(namespaceTemplate);
    runtimeNamespace.metadata.name = namespace;
    label(runtimeNamespace.metadata, profileLabels);
    annotate(runtimeNamespace.metadata, annotations);
    const runtimePath = runtimePathForProfile(profile, deploy);
    const endpoints = profileEndpoint(args, profile);
    putJson(`${runtimePath}/kustomization.yaml`, runtimeKustomization({ profile, includeDeviceAgent, externalPostgres: Boolean(externalPostgres), workbenchRedis: Boolean(workbenchRedis), runtimeConfigMaps: runtimeConfigMaps.length > 0, externalSecrets: Boolean(runtimeSecretPlane), deploy }));
    putJson(`${runtimePath}/namespace.yaml`, runtimeNamespace);
    if (!isRuntimeLane(profile)) putJson(`${runtimePath}/code-agent-codex-config.yaml`, transformListNamespace(config, namespace, profileLabels, annotations));
    putJson(`${runtimePath}/services.yaml`, transformServices({ services, namespace, labels: profileLabels, annotations, profile, deploy, nodeId: args.nodeId }));
    putJson(`${runtimePath}/health-contract.yaml`, transformHealthContract(health, namespace, profileLabels, annotations, endpoints.runtimeEndpoint, endpoints.webEndpoint, profile));
    if (runtimeConfigMaps.length > 0) putJson(`${runtimePath}/configmaps.yaml`, runtimeConfigMapsManifest({ configMaps: runtimeConfigMaps, namespace, labels: profileLabels, annotations }));
    if (runtimeSecretPlane) putJson(`${runtimePath}/external-secrets.yaml`, runtimeSecretPlaneManifest({ config: runtimeSecretPlane, namespace, labels: profileLabels, annotations }));
    putJson(`${runtimePath}/workloads.yaml`, transformWorkloads({ workloads, deploy, catalog: artifactCatalog, source, sourceBranch: args.sourceBranch, gitReadUrl: args.gitReadUrl, registryPrefix: args.registryPrefix, runtimeEndpoint: endpoints.runtimeEndpoint, webEndpoint: endpoints.webEndpoint, profile, nodeId: args.nodeId, useDeployImages: args.useDeployImages, metricsSidecarSha256 }));
    putJson(`${runtimePath}/deepseek-proxy.yaml`, deepSeekProxyManifest({ profile, source, sourceBranch: args.sourceBranch, sourceRepo: args.sourceRepo, gitReadUrl: args.gitReadUrl, deploy, registryPrefix: args.registryPrefix, catalog: artifactCatalog, useDeployImages: args.useDeployImages, metricsSidecarSha256 }));
    if (isRuntimeLane(profile) && externalPostgres) putJson(`${runtimePath}/external-postgres.yaml`, externalPostgresManifest({
      profile,
      config: externalPostgres,
      source,
      deploy,
      migrationSources,
      catalog: artifactCatalog,
      registryPrefix: args.registryPrefix,
      useDeployImages: args.useDeployImages
    }));
    if (isRuntimeLane(profile) && !externalPostgres) putJson(`${runtimePath}/postgres.yaml`, v02PostgresManifest({ profile, migrationSources, source, image: runtimePostgresImageForProfile(deploy, profile), deploy }));
    if (workbenchRedis) putJson(`${runtimePath}/workbench-redis.yaml`, workbenchRuntimeRedisManifest({ profile, config: workbenchRedis, source, deploy }));
    if (isRuntimeLane(profile)) putJson(`${runtimePath}/openfga.yaml`, v02OpenFgaManifest({ profile, source, deploy }));
    if (isRuntimeLane(profile)) putJson(`${runtimePath}/observability.yaml`, observabilityManifest({ deploy, profile, namespace, labels: profileLabels, annotations, metricsSidecarScript }));
    if (isRuntimeLane(profile)) putJson(`${runtimePath}/opencode.yaml`, opencodeServerManifest({ profile, source, deploy, catalog: artifactCatalog, registryPrefix: args.registryPrefix, useDeployImages: args.useDeployImages, sourceBranch: args.sourceBranch, gitReadUrl: args.gitReadUrl, sourceRepo: args.sourceRepo }));
    if (includeDeviceAgent) putJson(`${runtimePath}/device-agent-71-freq.yaml`, deviceAgent71FreqManifest({ profile, source, registryPrefix: args.registryPrefix, catalog: artifactCatalog, useDeployImages: args.useDeployImages }));
    putJson(`${runtimePath}/node-frpc.yaml`, nodeFrpcManifest({ profile, source, deploy, args }));
  }
  return { files, source };
}

async function writeFiles(files) {
  for (const [filePath, content] of files) {
    const absolutePath = generatedPath(filePath);
    await mkdir(path.dirname(absolutePath), { recursive: true });
    await writeFile(absolutePath, content);
  }
}

async function cleanStaleGeneratedOutput(args) {
  if (!isRuntimeLane(args.lane)) return;
  for (const relativePath of [`runtime-${args.lane}`, `tekton-${args.lane}`]) {
    await rm(generatedPath(path.join(args.outDir, relativePath)), { recursive: true, force: true });
  }
}

async function checkFiles(files) {
  const mismatches = [];
  for (const [filePath, expected] of files) {
    let actual = null;
    try {
      actual = await readFile(generatedPath(filePath), "utf8");
    } catch (error) {
      if (error?.code !== "ENOENT") throw error;
    }
    if (actual !== expected) mismatches.push(filePath);
  }
  return mismatches;
}

async function main() {
  let args = parseArgs(process.argv.slice(2));
  if (args.help) {
    console.log(usage());
    return;
  }
  ensureObject(args, "args");
  const { files, source } = await plannedFiles(args);
  if (args.check) {
    const mismatches = await checkFiles(files);
    console.log(JSON.stringify({ ok: mismatches.length === 0, sourceCommit: source.full, outDir: args.outDir, checked: files.size, mismatches }, null, 2));
    process.exitCode = mismatches.length === 0 ? 0 : 1;
    return;
  }
  if (args.write) {
    await cleanStaleGeneratedOutput(args);
    await writeFiles(files);
  }
  console.log(JSON.stringify({ ok: true, sourceCommit: source.full, sourceShort: source.short, outDir: args.outDir, files: [...files.keys()] }, null, 2));
}

main().catch((error) => {
  console.error(error.stack || error.message);
  process.exitCode = 1;
});
