#!/usr/bin/env node

import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
import { resolve } from "node:path";

const [answersPath, outputPath] = process.argv.slice(2);
if (!answersPath || !outputPath) {
  throw new Error("usage: render_new_silo_bundle.mjs ANSWERS_ENV OUTPUT_DIR");
}

function parseAnswers(source) {
  const values = new Map();
  for (const [index, line] of source.split("\n").entries()) {
    if (!line || line.startsWith("#")) continue;
    const separator = line.indexOf("=");
    if (separator < 1) throw new Error(`invalid answers line ${index + 1}`);
    const key = line.slice(0, separator);
    const value = line.slice(separator + 1);
    if (!/^[A-Z][A-Z0-9_]*$/.test(key)) {
      throw new Error(`invalid answers key on line ${index + 1}: ${key}`);
    }
    if (value.includes("\r") || value.includes("\n")) {
      throw new Error(`multiline value is not supported: ${key}`);
    }
    values.set(key, value);
  }
  return values;
}

function required(values, key) {
  const value = values.get(key);
  if (!value) throw new Error(`missing required answer: ${key}`);
  return value;
}

function optional(values, key, fallback = "") {
  return values.get(key) || fallback;
}

function assertMatch(label, value, pattern) {
  if (!pattern.test(value)) throw new Error(`invalid ${label}: ${value}`);
}

function envLine(key, value) {
  if (/[\r\n]/.test(value)) throw new Error(`unsafe newline in ${key}`);
  return `${key}=${value}`;
}

const answers = parseAnswers(await readFile(resolve(answersPath), "utf8"));
const instanceId = required(answers, "INSTANCE_ID");
const orgId = required(answers, "ORGANIZATION_ID");
const orgSlug = required(answers, "ORGANIZATION_SLUG");
const port = required(answers, "HUB_PORT");
const workspaceRoot = required(answers, "WORKSPACE_ROOT");
const publicBaseUrl = required(answers, "PUBLIC_BASE_URL").replace(/\/$/, "");
const domain = new URL(publicBaseUrl).hostname;
const databasePassword = required(answers, "DATABASE_PASSWORD");
const databaseUrl = `postgresql://${encodeURIComponent(required(answers, "DATABASE_USER"))}:${encodeURIComponent(databasePassword)}@${required(answers, "DATABASE_HOST")}:${required(answers, "DATABASE_PORT")}/${encodeURIComponent(required(answers, "DATABASE_NAME"))}`;

assertMatch("INSTANCE_ID", instanceId, /^[a-z0-9](?:[a-z0-9-]{0,22}[a-z0-9])?$/);
assertMatch("organization slug", orgSlug, /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/);
assertMatch("port", port, /^[1-9][0-9]{1,4}$/);
assertMatch("database name", required(answers, "DATABASE_NAME"), /^[a-z_][a-z0-9_]*$/);
assertMatch("database user", required(answers, "DATABASE_USER"), /^[a-z_][a-z0-9_]*$/);
assertMatch("default role id", required(answers, "DEFAULT_ROLE_ID"), /^[a-z][a-z0-9-]*$/);
assertMatch("release id", required(answers, "RELEASE_ID"), /^[A-Za-z0-9._-]+$/);
if (!publicBaseUrl.startsWith("https://")) throw new Error("PUBLIC_BASE_URL must use https");
for (const [label, value] of [
  ["WORKSPACE_ROOT", workspaceRoot],
  ["DEPLOY_BASE", required(answers, "DEPLOY_BASE")],
  ["DEPLOY_SSH_KEY", required(answers, "DEPLOY_SSH_KEY")],
  ["CPH_BIN", required(answers, "CPH_BIN")],
]) {
  if (!value.startsWith("/") || /\s/.test(value)) {
    throw new Error(`${label} must be an absolute path without whitespace: ${value}`);
  }
}
if (Number(port) > 65535) throw new Error(`invalid port: ${port}`);
if (Buffer.byteLength(workspaceRoot) > 16) {
  throw new Error(`WORKSPACE_ROOT exceeds the 16-byte sandbox socket limit: ${workspaceRoot}`);
}

const outputDir = resolve(outputPath);
await mkdir(outputDir, { recursive: true, mode: 0o700 });
await chmod(outputDir, 0o700);

const base = required(answers, "DEPLOY_BASE");
const release = required(answers, "RELEASE_ID");
const releaseHub = `${base}/releases/${release}/hub`;
const secretDir = `${base}/.secrets/${instanceId}`;
const envPath = `${secretDir}/platform.env`;
const keyringPath = `${secretDir}/secret-keyring.json`;
const unit = `cph-hub-${instanceId}.service`;

const platformEnv = [
  "# Generated by hub/deploy/new_silo.sh. Install root:root mode 0600.",
  envLine("NODE_ENV", "production"),
  envLine("DATABASE_URL", databaseUrl),
  envLine("HUB_SILO_ORGANIZATION_ID", orgId),
  envLine("HUB_SYSTEMD_UNIT", unit),
  envLine("CPH_BIN", required(answers, "CPH_BIN")),
  envLine("HOST", "127.0.0.1"),
  envLine("PORT", port),
  envLine("HUB_PROJECT_WORKSPACE_ROOT", workspaceRoot),
  envLine("HUB_PUBLIC_BASE_URL", publicBaseUrl),
  envLine("HUB_SESSION_SECRET", required(answers, "HUB_SESSION_SECRET")),
  envLine("HUB_AGENT_MAX_TURNS", required(answers, "MAX_TURNS")),
  envLine("HUB_AGENT_MAX_CONCURRENT_RUNS", required(answers, "MAX_CONCURRENT_RUNS")),
  envLine("HUB_AGENT_MAX_RUN_SECONDS", required(answers, "MAX_RUN_SECONDS")),
  envLine("HUB_HTTP_BODY_LIMIT_BYTES", required(answers, "HTTP_BODY_LIMIT_BYTES")),
  envLine("HUB_MAX_FILES_PER_MESSAGE", required(answers, "MAX_FILES_PER_MESSAGE")),
  envLine("HUB_MAX_FILE_BYTES", required(answers, "MAX_FILE_BYTES")),
  envLine("HUB_HTTP_REQUESTS_PER_MINUTE", required(answers, "HTTP_REQUESTS_PER_MINUTE")),
  envLine("HUB_FEISHU_EVENTS_PER_MINUTE", required(answers, "FEISHU_EVENTS_PER_MINUTE")),
  envLine("HUB_FEISHU_LISTENER_ENABLED", "true"),
  envLine("HTTP_PROXY", "http://127.0.0.1:7890"),
  envLine("HTTPS_PROXY", "http://127.0.0.1:7890"),
  envLine("ALL_PROXY", "socks5h://127.0.0.1:7890"),
  envLine("NO_PROXY", "127.0.0.1,localhost,::1"),
  envLine("NODE_USE_ENV_PROXY", "1"),
  envLine("ANTHROPIC_DEFAULT_SONNET_MODEL", "anthropic/claude-sonnet-5"),
  envLine("CPH_SANDBOX_EXTRA_DENY_READ", `${envPath}:${keyringPath}`),
  "",
].join("\n");

const bootstrap = {
  organization: {
    id: orgId,
    slug: orgSlug,
    name: required(answers, "ORGANIZATION_NAME"),
  },
  owner: {
    openId: required(answers, "OWNER_OPEN_ID"),
    displayName: required(answers, "OWNER_DISPLAY_NAME"),
  },
  feishu: {
    appId: required(answers, "FEISHU_APP_ID"),
    appSecret: required(answers, "FEISHU_APP_SECRET"),
    botOpenId: required(answers, "FEISHU_BOT_OPEN_ID"),
  },
  provider: {
    providerId: required(answers, "PROVIDER_ID"),
    baseUrl: required(answers, "PROVIDER_BASE_URL"),
    authToken: required(answers, "PROVIDER_AUTH_TOKEN"),
  },
  teams: [],
};
const ownerUnionId = optional(answers, "OWNER_UNION_ID");
if (ownerUnionId) bootstrap.owner.unionId = ownerUnionId;

const deployEnv = [
  "# Source this file locally before deploy_platform.sh (contains no app/provider secrets).",
  envLine("PLATFORM_DEPLOY_HOST", required(answers, "DEPLOY_HOST")),
  envLine("PLATFORM_DEPLOY_SSH_KEY", required(answers, "DEPLOY_SSH_KEY")),
  envLine("PLATFORM_DEPLOY_USER", required(answers, "DEPLOY_USER")),
  envLine("PLATFORM_DEPLOY_PORT", required(answers, "DEPLOY_SSH_PORT")),
  envLine("PLATFORM_DEPLOY_HUB_PORT", port),
  envLine("PLATFORM_DEPLOY_BASE", base),
  envLine("PLATFORM_DEPLOY_RELEASE", release),
  envLine("PLATFORM_DEPLOY_INSTANCE", instanceId),
  envLine("PLATFORM_DEPLOY_WORKSPACE_ROOT", workspaceRoot),
  envLine("PLATFORM_DEPLOY_MEMORY_MAX", required(answers, "MEMORY_MAX")),
  envLine("PLATFORM_DEPLOY_CPU_QUOTA", required(answers, "CPU_QUOTA")),
  envLine("PLATFORM_DEPLOY_TASKS_MAX", required(answers, "TASKS_MAX")),
  envLine("PLATFORM_DEPLOY_HEALTH_URL", `http://127.0.0.1:${port}/api/healthz`),
  "",
].join("\n");

const nginx = `# Install as /etc/nginx/conf.d/${instanceId}.conf after obtaining TLS certificates.\nserver {\n  listen 80;\n  server_name ${domain};\n  return 301 https://$host$request_uri;\n}\n\nserver {\n  listen 443 ssl http2;\n  server_name ${domain};\n\n  ssl_certificate /etc/letsencrypt/live/${domain}/fullchain.pem;\n  ssl_certificate_key /etc/letsencrypt/live/${domain}/privkey.pem;\n\n  location / {\n    proxy_pass http://127.0.0.1:${port};\n    proxy_http_version 1.1;\n    proxy_set_header Host $host;\n    proxy_set_header X-Real-IP $remote_addr;\n    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n    proxy_set_header X-Forwarded-Proto https;\n  }\n}\n`;

const defaultRolePrompt = `你是一位教育机构智能助手，服务于学校和教培机构的日常管理与协作。
## 核心能力
**教务管理**：熟悉排课调课、班级管理、课时统计、师资调度等常见管理流程。
**教研支持**：理解课程设计、教案编写、例题/变式题、定理证明、随堂练习、阶段性测验、知识点拆解、大纲对标等教学业务概念。
**数字化操作**：熟练使用shell命令行进行文件管理、批量处理、脚本编写、文本处理（grep/sed/awk等）、自动化任务；能协助处理Markdown、Typst等文档、表格数据、格式转换等。
## 行为准则
- **实事求是**：不确定时明确说明，能力边界外的任务如实告知，不为完成目标而编造信息或勉强输出
- **简洁优先**：直接给出答案或方案，省略铺垫和客套
- **按需展开**：仅在问题复杂或用户明确要求时提供详细说明
- **结构清晰**：多步骤内容使用编号，便于飞书阅读
- **可操作性**：涉及操作时给出具体命令或步骤
保持专业、高效，像一位熟悉业务的同事。
`;

const operate = `# ${instanceId} deployment commands\n\nThis file contains no application/provider secrets. Run commands deliberately; do not source answers.env.\n\n## 1. DNS and Feishu\n\n- Point \`${domain}\` to \`${required(answers, "DEPLOY_HOST")}\`.\n- In the Feishu app, configure redirect URL: \`${publicBaseUrl}/auth/feishu/callback\`.\n- Enable the required bot/message/contact permissions and event subscription described in the customer setup document.\n\n## 2. Database (run as the PostgreSQL administrator)\n\nCreate one role and one database for this Silo. The password is in \`answers.env\`; use an interactive client or a protected SQL file, never a command-line argument.\n\n\`\`\`sql\nCREATE ROLE ${required(answers, "DATABASE_USER")} LOGIN PASSWORD '<copy DATABASE_PASSWORD from answers.env>';\nCREATE DATABASE ${required(answers, "DATABASE_NAME")} OWNER ${required(answers, "DATABASE_USER")};\n\`\`\`\n\n## 3. Publish the immutable release\n\nThe normal deploy script seeds the first-instance secrets and exits 78. That exit is expected only on this first pass.\n\n\`\`\`sh\nset -a; . ./deploy.env; set +a\nbash hub/deploy/deploy_platform.sh\n\`\`\`\n\n## 4. Install the generated secrets\n\nCopy \`platform.env\` and \`bootstrap.json\` to the server through a protected channel. On the server:\n\n\`\`\`sh\ninstall -d -o root -g root -m 0700 '${secretDir}'\ninstall -o root -g root -m 0600 platform.env '${envPath}'\ninstall -o root -g root -m 0600 bootstrap.json '/root/${instanceId}-bootstrap.json'\n# Copy ${keyringPath} to separate off-host recovery storage before continuing.\n\`\`\`\n\n## 5. Migrate, install the stopped service, and bootstrap\n\n\`\`\`sh\nset -a; . '${envPath}'; set +a\nnode '${releaseHub}/node_modules/prisma/build/index.js' migrate deploy --schema '${releaseHub}/prisma/schema.prisma'\nBASE='${base}' HUB_DIR='${releaseHub}' INSTANCE_ID='${instanceId}' WORKSPACE_ROOT='${workspaceRoot}' PORT='${port}' MEMORY_MAX='${required(answers, "MEMORY_MAX")}' CPU_QUOTA='${required(answers, "CPU_QUOTA")}' TASKS_MAX='${required(answers, "TASKS_MAX")}' bash '${releaseHub}/deploy/install_service.sh'\nnode '${releaseHub}/dist/deployment/bootstrap-silo-cli.js' --config-file '/root/${instanceId}-bootstrap.json' --keyring-file '${keyringPath}'\nrm -f '/root/${instanceId}-bootstrap.json'\n\`\`\`\n\n## 6. Runtime role and skills\n\nRuntime role/skill configuration is intentionally separate from the release. Follow NEW_SILO_RUNBOOK.md, staging each approved skill directory outside the immutable release, then use \`agent_config.sh\`. Do not silently copy a local personal skill collection.\n\n## 7. Nginx, start, and acceptance\n\nInstall \`nginx.conf\`, run \`nginx -t\`, reload Nginx, then:\n\n\`\`\`sh\nsystemctl start '${unit}'\nsystemctl is-active '${unit}'\ncurl --fail --silent --show-error 'http://127.0.0.1:${port}/api/healthz'\ncurl --fail --silent --show-error '${publicBaseUrl}/api/healthz'\njournalctl -u '${unit}' --since '-10 min' --no-pager\n\`\`\`\n\nAcceptance requires: Feishu OAuth completes, OWNER and a non-OWNER can @bot, a second group creates a separate project/session, Typst works when that skill is enabled, and restart preserves session cursor. Then take the first off-host backup.\n\n## 8. Later releases\n\nAfter the instance exists, source \`deploy.env\` with the new release id and run \`deploy_platform.sh\`. Never reuse another org's database, secret directory, workspace root, port, service identity, domain, Feishu app, or provider credential.\n`;

const selectedSkills = optional(answers, "APPROVED_SKILLS");
const roleInstructions = `
## Appendix: default runtime role

Copy \`default-role-prompt.md\` to \`/root/${instanceId}-default-role-prompt.md\`.
After installing each reviewed skill with \`agent_config.sh install-skill\`, run:

\`\`\`sh
INSTANCE_ID=${JSON.stringify(instanceId)} ENV_FILE=${JSON.stringify(envPath)} HUB_DIR=${JSON.stringify(releaseHub)} bash ${JSON.stringify(`${releaseHub}/deploy/agent_config.sh`)} upsert-role --organization ${JSON.stringify(orgId)} --role ${JSON.stringify(required(answers, "DEFAULT_ROLE_ID"))} --label ${JSON.stringify(required(answers, "DEFAULT_ROLE_LABEL"))} --model ${JSON.stringify(required(answers, "DEFAULT_MODEL"))} --system-prompt-file ${JSON.stringify(`/root/${instanceId}-default-role-prompt.md`)} --tools-json null
${selectedSkills ? `INSTANCE_ID=${JSON.stringify(instanceId)} ENV_FILE=${JSON.stringify(envPath)} HUB_DIR=${JSON.stringify(releaseHub)} bash ${JSON.stringify(`${releaseHub}/deploy/agent_config.sh`)} set-role-skills --organization ${JSON.stringify(orgId)} --role ${JSON.stringify(required(answers, "DEFAULT_ROLE_ID"))} --skills ${JSON.stringify(selectedSkills)}
` : "# No skills selected in the wizard; set-role-skills remains an explicit operator step.\n"}INSTANCE_ID=${JSON.stringify(instanceId)} ENV_FILE=${JSON.stringify(envPath)} HUB_DIR=${JSON.stringify(releaseHub)} bash ${JSON.stringify(`${releaseHub}/deploy/agent_config.sh`)} list --organization ${JSON.stringify(orgId)}
\`\`\`

Changing a role's model, system prompt, tools or skill selection archives that
role's existing sessions by design. Finish this setup before inviting Alpha users.
`;

async function privateWrite(name, contents) {
  const path = resolve(outputDir, name);
  await writeFile(path, contents, { encoding: "utf8", mode: 0o600 });
  await chmod(path, 0o600);
}

await privateWrite("platform.env", platformEnv);
await privateWrite("bootstrap.json", `${JSON.stringify(bootstrap, null, 2)}\n`);
await privateWrite("deploy.env", deployEnv);
await privateWrite("nginx.conf", nginx);
await privateWrite("default-role-prompt.md", defaultRolePrompt);
await privateWrite("OPERATE.md", `${operate}${roleInstructions}`);

console.log(`Rendered private Silo bundle: ${outputDir}`);
