#!/usr/bin/env bun
/**
 * Nucleus Project Scaffolding Script
 *
 * Reads a config.json, validates it, and generates a full-mode API + frontend project
 * with k8s manifests, azure-pipelines, .env files, and config.ts injection.
 *
 * Usage: bun run infra/scripts/generate-project.ts
 */

import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, statSync } from 'fs'
import { join, resolve } from 'path'
import { createInterface } from 'readline/promises'
import { spawnSync } from 'child_process'

// ─── Helpers ───────────────────────────────────────────────────────────────────

const BOLD = '\x1b[1m'
const DIM = '\x1b[2m'
const GREEN = '\x1b[32m'
const CYAN = '\x1b[36m'
const YELLOW = '\x1b[33m'
const RED = '\x1b[31m'
const RESET = '\x1b[0m'

function log(msg: string) {
  console.log(msg)
}

function logStep(step: string) {
  log(`\n${CYAN}${BOLD}▸ ${step}${RESET}`)
}

function logSuccess(msg: string) {
  log(`  ${GREEN}✓${RESET} ${msg}`)
}

function logWarn(msg: string) {
  log(`  ${YELLOW}⚠${RESET} ${msg}`)
}

function logError(msg: string) {
  log(`  ${RED}✗${RESET} ${msg}`)
}

type ReadlineInterface = ReturnType<typeof createInterface>

function createPromptFunctions(rl: ReadlineInterface) {
  async function prompt(question: string, defaultValue?: string): Promise<string> {
    const suffix = defaultValue ? ` ${DIM}(${defaultValue})${RESET}` : ''
    const answer = await rl.question(`  ${question}${suffix}: `)
    const trimmed = answer.trim()
    return trimmed || defaultValue || ''
  }

  async function promptRequired(question: string): Promise<string> {
    let value = ''
    while (!value) {
      value = await prompt(question)
      if (!value) logError('This field is required.')
    }
    return value
  }

  async function promptNumber(question: string, defaultValue: number, min: number, max: number): Promise<number> {
    const raw = await prompt(question, String(defaultValue))
    const num = Number.parseInt(raw, 10)
    if (Number.isNaN(num) || num < min || num > max) {
      logWarn(`Invalid. Using default: ${defaultValue}`)
      return defaultValue
    }
    return num
  }

  return { prompt, promptRequired, promptNumber }
}

function generateSecret(length: number = 32): string {
  const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'
  let result = ''
  const bytes = new Uint8Array(length)
  crypto.getRandomValues(bytes)
  for (const b of bytes) {
    result += chars[b % chars.length]
  }
  return result
}

function slugify(name: string): string {
  return name
    .toLowerCase()
    .replace(/[^a-z0-9-]/g, '-')
    .replace(/-+/g, '-')
    .replace(/^-|-$/g, '')
}

// ─── Template Engine ───────────────────────────────────────────────────────────

type TemplateVars = Record<string, string>

function replaceTemplateVars(content: string, vars: TemplateVars): string {
  let result = content
  for (const [key, value] of Object.entries(vars)) {
    result = result.replaceAll(`{{${key}}}`, value)
  }
  return result
}

const BINARY_EXTENSIONS = new Set([
  '.ico', '.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.bmp',
  '.woff', '.woff2', '.ttf', '.eot', '.otf',
  '.mp3', '.mp4', '.wav', '.ogg', '.webm',
  '.zip', '.gz', '.tar', '.pdf',
  '.lock',
])

function isBinaryFile(filePath: string): boolean {
  const ext = filePath.slice(filePath.lastIndexOf('.')).toLowerCase()
  return BINARY_EXTENSIONS.has(ext)
}

function copyTemplateDir(srcDir: string, destDir: string, vars: TemplateVars, skipPatterns: string[] = []) {
  if (!existsSync(srcDir)) return

  mkdirSync(destDir, { recursive: true })

  for (const entry of readdirSync(srcDir)) {
    if (skipPatterns.includes(entry)) continue

    const srcPath = join(srcDir, entry)
    const destPath = join(destDir, entry)
    const stat = statSync(srcPath)

    if (stat.isDirectory()) {
      copyTemplateDir(srcPath, destPath, vars, skipPatterns)
    } else if (isBinaryFile(srcPath)) {
      writeFileSync(destPath, readFileSync(srcPath))
    } else {
      const content = readFileSync(srcPath, 'utf-8')
      const processed = replaceTemplateVars(content, vars)
      writeFileSync(destPath, processed)
    }
  }
}

// ─── Config Validation ─────────────────────────────────────────────────────────

type FeatureSection = {
  enabled?: boolean
  [key: string]: unknown
}

type NucleusConfig = {
  appId: string
  mode: string
  database: { url: string; isMultiTenant?: boolean }
  redis: { host: string; port: number | string; withDapr: boolean | string }
  authentication: Record<string, unknown> & { enabled?: boolean; mode?: string }
  entities: Array<Record<string, unknown>>
  [key: string]: unknown
}

const ENV_VAR_PATTERN = /^[A-Z][A-Z0-9_]{2,}$/

/** Uppercase enum-ish values that are NOT env var references. */
const ENV_VAR_FALSE_POSITIVES = new Set([
  'GET',
  'POST',
  'PUT',
  'DELETE',
  'PATCH',
  'TOGGLE',
  'VERIFICATION',
  'OPTIONS',
  'HEAD',
  'INSERT',
  'UPDATE',
])

/** Config subtrees that never carry env var references. */
const ENV_SCAN_SKIP_KEYS = new Set(['entities', 'externalEntities', 'seed'])

/**
 * Walks the config and collects every string value that looks like an env var
 * reference (UPPER_SNAKE_CASE). Nucleus resolves such values from process.env
 * at runtime, so each one needs an entry in .env / K8s secrets.
 */
function collectEnvVarReferences(value: unknown, found: Set<string> = new Set()): Set<string> {
  if (typeof value === 'string') {
    if (ENV_VAR_PATTERN.test(value) && !ENV_VAR_FALSE_POSITIVES.has(value)) found.add(value)
    return found
  }
  if (Array.isArray(value)) {
    for (const item of value) collectEnvVarReferences(item, found)
    return found
  }
  if (typeof value === 'object' && value !== null) {
    for (const [key, v] of Object.entries(value)) {
      if (ENV_SCAN_SKIP_KEYS.has(key)) continue
      collectEnvVarReferences(v, found)
    }
  }
  return found
}

function isFeatureEnabled(section: unknown): boolean {
  return (
    typeof section === 'object' && section !== null && (section as FeatureSection).enabled === true
  )
}

function validateConfig(configPath: string): NucleusConfig {
  if (!existsSync(configPath)) {
    logError(`Config file not found: ${configPath}`)
    process.exit(1)
  }

  const raw = readFileSync(configPath, 'utf-8')
  let parsed: NucleusConfig

  try {
    parsed = JSON.parse(raw) as NucleusConfig
  } catch {
    logError('Invalid JSON in config file.')
    process.exit(1)
  }

  if (!parsed.appId) {
    logError('Config must have an "appId" field.')
    process.exit(1)
  }
  if (!parsed.database?.url) {
    logError('Config must have a "database.url" field.')
    process.exit(1)
  }
  if (!parsed.authentication?.enabled) {
    logWarn('Authentication is not enabled. Auth tables/routes will not be generated.')
  } else if (!parsed.authentication.mode) {
    logWarn('authentication.mode is missing — set "full" (IDP) or "consumer" (resource server).')
  } else if (parsed.authentication.mode === 'consumer') {
    const authz = parsed.authorization as FeatureSection | undefined
    if (authz?.enabled && !(parsed.authentication as Record<string, unknown>).idpUrl) {
      logWarn('Consumer mode with authorization enabled needs authentication.idpUrl for /auth/check.')
    }
  }
  if (!parsed.entities || parsed.entities.length === 0) {
    logWarn('No "entities" found. No CRUD endpoints will be generated.')
  }

  const notification = parsed.notification as
    | { enabled?: boolean; channels?: Record<string, FeatureSection | boolean> }
    | undefined
  if (notification?.enabled) {
    const telegram = notification.channels?.telegram as FeatureSection | undefined
    if (telegram?.enabled && (!telegram.botToken || !telegram.chatId)) {
      logWarn('notification.channels.telegram is enabled but botToken/chatId is missing.')
    }
    const webhook = notification.channels?.webhook as FeatureSection | undefined
    if (webhook?.enabled && !webhook.url) {
      logWarn('notification.channels.webhook is enabled but url is missing.')
    }
    if (notification.channels?.email === true && !parsed.email) {
      logWarn('notification.channels.email is enabled but no config.email provider is set.')
    }
  }

  const payment = parsed.payment as
    | { enabled?: boolean; provider?: string; iyzico?: unknown; stripe?: unknown }
    | undefined
  if (payment?.enabled) {
    if (!payment.provider) {
      logWarn('payment.enabled is true but payment.provider is missing ("iyzico" or "stripe").')
    } else if (!payment[payment.provider as 'iyzico' | 'stripe']) {
      logWarn(`payment.provider is "${payment.provider}" but payment.${payment.provider} config is missing.`)
    }
  }

  const monitoring = parsed.monitoring as
    | { enabled?: boolean; redis?: FeatureSection }
    | undefined
  const redisWithDapr =
    parsed.redis?.withDapr === true ||
    (typeof parsed.redis?.withDapr === 'string' && parsed.redis.withDapr !== 'false')
  if (monitoring?.enabled && monitoring.redis?.enabled && redisWithDapr) {
    logWarn('monitoring.redis needs direct Redis mode — it is skipped when redis.withDapr is set.')
  }

  const chat = parsed.chat as { enabled?: boolean; attachments?: FeatureSection } | undefined
  const storage = parsed.storage as { enabled?: boolean; cdn?: FeatureSection } | undefined
  if (chat?.enabled && chat.attachments?.enabled && !(storage?.enabled && storage.cdn?.enabled)) {
    logWarn('chat.attachments requires storage.enabled and storage.cdn.enabled.')
  }

  return parsed
}

function printConfigSummary(config: NucleusConfig) {
  log(`\n  ${BOLD}Config Summary${RESET}`)
  log(`  ─────────────────────────────────────`)
  log(`  ${BOLD}App ID:${RESET}         ${config.appId}`)
  log(`  ${BOLD}Mode:${RESET}           ${config.mode || 'not set'}`)
  log(`  ${BOLD}Database:${RESET}       ${config.database.url === 'DATABASE_URL' ? 'env var' : 'inline'}${config.database.isMultiTenant ? ' (multi-tenant)' : ''}`)
  const redisMode =
    config.redis?.withDapr === true || (typeof config.redis?.withDapr === 'string' && config.redis.withDapr !== 'false')
      ? 'Dapr state store'
      : `${config.redis?.host || 'not configured'}:${config.redis?.port || '-'}`
  log(`  ${BOLD}Redis:${RESET}          ${redisMode}`)
  const authMode = config.authentication?.enabled
    ? `enabled (${config.authentication.mode || 'mode missing!'})`
    : 'disabled'
  log(`  ${BOLD}Auth:${RESET}           ${authMode}`)
  log(`  ${BOLD}Entities:${RESET}       ${config.entities?.length || 0} table(s)`)

  if (config.entities?.length > 0) {
    for (const entity of config.entities) {
      const cols = (entity.columns as Array<Record<string, unknown>>)?.length || 0
      log(`    • ${entity.table_name} (${cols} columns)`)
    }
  }

  // Every optional feature section, checked by its actual `enabled` flag.
  const featureChecks: Array<[string, boolean]> = [
    ['Authorization', isFeatureEnabled(config.authorization)],
    ['Verification', isFeatureEnabled(config.verification)],
    ['Notification', isFeatureEnabled(config.notification)],
    ['Chat', isFeatureEnabled(config.chat)],
    ['Audit', isFeatureEnabled(config.audit)],
    ['Backup', isFeatureEnabled(config.backup)],
    ['Storage', isFeatureEnabled(config.storage)],
    [
      'CDN',
      isFeatureEnabled(config.storage) &&
        isFeatureEnabled((config.storage as FeatureSection).cdn),
    ],
    [
      `Payment${(config.payment as FeatureSection)?.provider ? ` (${(config.payment as FeatureSection).provider})` : ''}`,
      isFeatureEnabled(config.payment),
    ],
    ['PubSub', isFeatureEnabled(config.pubsub)],
    ['Monitoring', isFeatureEnabled(config.monitoring)],
    ['Live Monitoring', isFeatureEnabled(config.liveMonitoring)],
    ['Rate Limit', isFeatureEnabled(config.rateLimit)],
    ['Config Management', isFeatureEnabled(config.configManagement)],
    [
      `Email${(config.email as FeatureSection)?.provider ? ` (${(config.email as FeatureSection).provider})` : ''}`,
      Boolean(config.email),
    ],
    [
      'OAuth',
      isFeatureEnabled((config.authentication as Record<string, unknown>)?.oauth),
    ],
    [
      'API Keys',
      isFeatureEnabled((config.authentication as Record<string, unknown>)?.apiKeys),
    ],
    [
      'WebAuthn',
      isFeatureEnabled((config.authentication as Record<string, unknown>)?.webauthn),
    ],
  ]

  const features = featureChecks.filter(([, on]) => on).map(([name]) => name)
  if (features.length > 0) {
    log(`  ${BOLD}Features:${RESET}       ${features.join(', ')}`)
  }

  const envRefs = collectEnvVarReferences(config)
  if (envRefs.size > 0) {
    log(`  ${BOLD}Env vars:${RESET}       ${envRefs.size} referenced (${Array.from(envRefs).slice(0, 6).join(', ')}${envRefs.size > 6 ? ', …' : ''})`)
  }
  log(`  ─────────────────────────────────────`)
}

// ─── Generators ────────────────────────────────────────────────────────────────

function generateBackend(
  outputDir: string,
  projectName: string,
  config: NucleusConfig,
  vars: TemplateVars,
  templateDir: string,
) {
  logStep(`Generating backend: ${projectName}`)

  const beDir = join(outputDir, projectName)
  mkdirSync(beDir, { recursive: true })

  // Copy entire backend template (source code, package.json, tsconfig, etc.)
  copyTemplateDir(templateDir, beDir, vars, ['node_modules', '.git', 'bun.lock'])

  // Write the config.json into src/
  const srcDir = join(beDir, 'src')
  mkdirSync(srcDir, { recursive: true })
  writeFileSync(join(srcDir, 'config.json'), JSON.stringify(config, null, 2))
  logSuccess('src/config.json written')

  // Write .env for local dev — cover every env var the config references.
  const auth = config.authentication as Record<string, Record<string, string>> | undefined
  const accessSecretName = auth?.accessToken?.secret || 'ACCESS_TOKEN_SECRET'
  const refreshSecretName = auth?.refreshToken?.secret || 'REFRESH_TOKEN_SECRET'
  const sessionSecretName = auth?.sessionToken?.secret || 'SESSION_TOKEN_SECRET'

  const knownValues: Record<string, string> = {
    DATABASE_URL: `postgresql://postgres:postgres@localhost:5432/${projectName}`,
    [accessSecretName]: generateSecret(),
    [refreshSecretName]: generateSecret(),
    [sessionSecretName]: generateSecret(),
    REDIS_HOST: 'localhost',
    REDIS_PORT: '6379',
    REDIS_URL: 'redis://localhost:6379',
    PORT: '9000',
    FRONTEND_URL: 'http://localhost:3000',
    USE_DAPR: 'false',
    COOKIE_DOMAIN: 'localhost',
  }

  const alwaysEmit = [
    'DATABASE_URL',
    accessSecretName,
    refreshSecretName,
    sessionSecretName,
    'REDIS_HOST',
    'REDIS_PORT',
    'PORT',
    'FRONTEND_URL',
    'USE_DAPR',
  ]

  const envLines: string[] = []
  const emitted = new Set<string>()
  for (const name of alwaysEmit) {
    envLines.push(`${name}=${knownValues[name] ?? ''}`)
    emitted.add(name)
  }

  // Every other env var referenced in the config (OAuth secrets, payment keys,
  // email connection strings, notification tokens, …) gets a placeholder so
  // nothing silently resolves to undefined at runtime.
  const referenced = Array.from(collectEnvVarReferences(config))
    .filter((name) => !emitted.has(name))
    .sort()
  if (referenced.length > 0) {
    envLines.push('', '# Referenced in config.json — fill in before enabling the related feature')
    for (const name of referenced) {
      envLines.push(`${name}=${knownValues[name] ?? ''}`)
      if (!knownValues[name]) {
        logWarn(`.env: ${name} needs a value (referenced in config.json)`)
      }
    }
  }

  writeFileSync(join(beDir, '.env'), envLines.join('\n') + '\n')
  logSuccess(`.env written (local dev, ${envLines.filter((l) => l.includes('=')).length} vars)`)

  // K8s files are already copied from template with vars replaced
  logSuccess('k8s/ manifests written')
  logSuccess('azure-pipelines.yml written')
  logSuccess(`.dockerignore written`)

  // Update package.json name
  const pkgPath = join(beDir, 'package.json')
  if (existsSync(pkgPath)) {
    const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'))
    pkg.name = projectName
    writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n')
    logSuccess('package.json name updated')
  }

  log(`  ${GREEN}${BOLD}Backend ready at:${RESET} ${beDir}`)
}

function generateFrontend(
  outputDir: string,
  projectName: string,
  apiProjectName: string,
  config: NucleusConfig,
  vars: TemplateVars,
  templateDir: string,
) {
  logStep(`Generating frontend: ${projectName}`)

  const feDir = join(outputDir, projectName)
  mkdirSync(feDir, { recursive: true })

  // Copy entire frontend template
  copyTemplateDir(templateDir, feDir, vars, ['node_modules', '.git', '.next', 'bun.lock'])

  // Write config.ts into lib/api/
  const libApiDir = join(feDir, 'lib', 'api')
  mkdirSync(libApiDir, { recursive: true })
  const configTsContent = `export const config = ${JSON.stringify(config, null, 2)} as const;\n`
  writeFileSync(join(libApiDir, 'config.ts'), configTsContent)
  logSuccess('lib/api/config.ts written (from config.json)')

  // Write .env for production build (non-secret build-time vars)
  // NOTE: PORT and HOSTNAME are NOT set here — they come from K8s configmap in prod.
  // In local dev, server.ts defaults to port 4000 and Next.js to 3000 — no conflict.
  const envContent = [
    `# Production build environment variables`,
    `# These are injected via Docker build args in the pipeline`,
    `# DO NOT put secrets here — use K8s Secrets`,
    `# PORT and HOSTNAME are set by K8s configmap, not here`,
    `API_BASE_URL=http://${apiProjectName}-service.${vars.NAMESPACE}.svc.cluster.local:3000`,
    `BACKEND_API_URL=http://${apiProjectName}-service.${vars.NAMESPACE}.svc.cluster.local:3000`,
    `ALLOWED_ORIGINS=${vars.DOMAIN}`,
  ].join('\n')
  writeFileSync(join(feDir, '.env'), envContent + '\n')
  logSuccess('.env written (build/prod)')

  // Write .env.local for local dev
  const envLocalContent = [
    `# PUBLIC ENV VARS ARE STRICTLY PROHIBITED`,
    `# This file is for LOCAL DEVELOPMENT ONLY — never commit it`,
    `# In production, all envs come from ConfigMap/Secrets via Dockerfile ARGs`,
    ``,
    `# Backend API URL (local dev — point to your local API or kubectl port-forward)`,
    `API_BASE_URL=http://localhost:9000`,
    `BACKEND_API_URL=http://localhost:9000`,
    ``,
    `# Server Actions encryption key`,
    `NEXT_SERVER_ACTIONS_ENCRYPTION_KEY="${generateSecret(32)}"`,
  ].join('\n')
  writeFileSync(join(feDir, '.env.local'), envLocalContent + '\n')
  logSuccess('.env.local written (local dev)')

  // K8s files already copied with vars replaced
  logSuccess('k8s/ manifests written')
  logSuccess('azure-pipelines.yml written')
  logSuccess('.dockerignore written')

  // Update package.json name
  const pkgPath = join(feDir, 'package.json')
  if (existsSync(pkgPath)) {
    const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'))
    pkg.name = projectName
    writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n')
    logSuccess('package.json name updated')
  }

  // Ensure .gitignore includes .env.local
  const gitignorePath = join(feDir, '.gitignore')
  if (existsSync(gitignorePath)) {
    let gi = readFileSync(gitignorePath, 'utf-8')
    if (!gi.includes('.env.local')) {
      gi += '\n.env.local\n'
      writeFileSync(gitignorePath, gi)
      logSuccess('.gitignore updated (added .env.local)')
    }
  }

  log(`  ${GREEN}${BOLD}Frontend ready at:${RESET} ${feDir}`)
}

// ─── Main ──────────────────────────────────────────────────────────────────────

export async function scaffold(infraDir: string) {
  const rl = createInterface({
    input: process.stdin,
    output: process.stdout,
  })
  const { prompt, promptRequired, promptNumber } = createPromptFunctions(rl)

  try {
    log(`\n${CYAN}${BOLD}╔══════════════════════════════════════════╗${RESET}`)
    log(`${CYAN}${BOLD}║   Nucleus Project Scaffolding            ║${RESET}`)
    log(`${CYAN}${BOLD}╚══════════════════════════════════════════╝${RESET}`)

    // ── Step 1: Get config.json path ──
    logStep('Configuration')
    const configPath = resolve(await promptRequired('Path to config.json'))
    const config = validateConfig(configPath)
    printConfigSummary(config)

    // ── Step 2: Get output path ──
    logStep('Output')
    const outputDir = resolve(await promptRequired('Output directory (where to create projects)'))

    if (existsSync(outputDir)) {
      const contents = readdirSync(outputDir)
      if (contents.length > 0) {
        logWarn(`Directory exists and is not empty: ${outputDir}`)
        const cont = await prompt('Continue anyway? (y/n)', 'y')
        if (cont.toLowerCase() !== 'y') {
          log('\nAborted.')
          process.exit(0)
        }
      }
    }
    mkdirSync(outputDir, { recursive: true })

    // ── Step 3: Project naming ──
    logStep('Project Setup')
    const appSlug = slugify(config.appId)
    const apiName = await prompt('API project name', `${appSlug}-api`)
    const apiProjectName = slugify(apiName)

    // ── Step 4: Frontend count (1 for now) ──
    const feCount = await promptNumber('Number of frontend projects (max 1 for now)', 1, 0, 1)

    let feProjectName = ''
    if (feCount > 0) {
      const feName = await prompt('Frontend project name', `${appSlug}-web`)
      feProjectName = slugify(feName)
    }

    // ── Step 5: Infra config ──
    logStep('Infrastructure')
    const domain = await prompt('Production domain', `${appSlug}.example.com`)
    const namespace = await prompt('K8s namespace', 'default')
    const acrRegistry = await prompt('ACR registry', 'myacr.azurecr.io')
    const aksResourceGroup = await prompt('AKS resource group', 'my-resource-group')
    const aksClusterName = await prompt('AKS cluster name', 'my-aks-cluster')

    // ── Build template vars ──
    const beTemplateDir = join(infraDir, 'templates', 'backend')
    const feTemplateDir = join(infraDir, 'templates', 'frontend')

    const beVars: TemplateVars = {
      PROJECT_NAME: apiProjectName,
      PROJECT_DIR: apiProjectName,
      NAMESPACE: namespace,
      DOMAIN: domain,
      ACR_REGISTRY: acrRegistry,
      AKS_RESOURCE_GROUP: aksResourceGroup,
      AKS_CLUSTER_NAME: aksClusterName,
      API_SERVICE_NAME: apiProjectName,
    }

    const feVars: TemplateVars = {
      PROJECT_NAME: feProjectName,
      PROJECT_DIR: feProjectName,
      NAMESPACE: namespace,
      DOMAIN: domain,
      ACR_REGISTRY: acrRegistry,
      AKS_RESOURCE_GROUP: aksResourceGroup,
      AKS_CLUSTER_NAME: aksClusterName,
      API_SERVICE_NAME: apiProjectName,
    }

    // ── Confirmation ──
    logStep('Review')
    log(`\n  ${BOLD}Will generate:${RESET}`)
    log(`  ─────────────────────────────────────`)
    log(`  ${BOLD}Output:${RESET}      ${outputDir}`)
    log(`  ${BOLD}API:${RESET}         ${apiProjectName}/ (full mode)`)
    if (feCount > 0) {
      log(`  ${BOLD}Frontend:${RESET}    ${feProjectName}/`)
    }
    log(`  ${BOLD}Domain:${RESET}      ${domain}`)
    log(`  ${BOLD}Namespace:${RESET}   ${namespace}`)
    log(`  ${BOLD}ACR:${RESET}         ${acrRegistry}`)
    log(`  ─────────────────────────────────────`)

    const confirm = await prompt('Proceed? (y/n)', 'y')
    if (confirm.toLowerCase() !== 'y') {
      log('\nAborted.')
      process.exit(0)
    }

    // ── Generate ──
    logStep('Generating projects...')

    generateBackend(outputDir, apiProjectName, config, beVars, beTemplateDir)

    if (feCount > 0) {
      generateFrontend(outputDir, feProjectName, apiProjectName, config, feVars, feTemplateDir)
    }

    // ── Post-scaffold automation ──
    logStep('Installing dependencies & setting up...')

    const beDir = join(outputDir, apiProjectName)

    // Backend: bun install
    logStep(`[API] bun install`)
    const installBe = spawnSync('bun', ['install'], { cwd: beDir, stdio: ['pipe', 'pipe', 'pipe'] })
    if (installBe.status === 0) {
      logSuccess('bun install completed')
    } else {
      logError(`bun install failed: ${(installBe.stderr ?? '').toString().slice(0, 200)}`)
      process.exit(1)
    }

    // Backend: create database (best-effort — skip if createdb not found or DB already exists)
    logStep(`[API] Creating database: ${apiProjectName}`)
    const createDb = spawnSync('createdb', [apiProjectName], { cwd: beDir, stdio: ['pipe', 'pipe', 'pipe'] })
    if (createDb.status === 0) {
      logSuccess(`Database "${apiProjectName}" created`)
    } else {
      const errMsg = (createDb.stderr ?? '').toString()
      if (errMsg.includes('already exists')) {
        logWarn(`Database "${apiProjectName}" already exists — using existing`)
      } else if (errMsg.includes('not found') || errMsg.includes('No such file') || createDb.error) {
        logWarn('createdb not found — please create the database manually')
      } else {
        logWarn(`createdb: ${errMsg.trim().slice(0, 150)}`)
      }
    }

    // Backend: nucleus-generate (schema + relations)
    logStep(`[API] Generating Drizzle schema`)
    const generate = spawnSync('bunx', ['nucleus-generate', 'src/config.json', 'src/drizzle'], { cwd: beDir, stdio: ['pipe', 'pipe', 'pipe'] })
    if (generate.status === 0) {
      logSuccess('Drizzle schema generated')
      const genOut = (generate.stdout ?? '').toString()
      if (genOut) log(`  ${DIM}${genOut.trim()}${RESET}`)
    } else {
      logError(`nucleus-generate failed: ${(generate.stderr ?? '').toString().slice(0, 200)}`)
      process.exit(1)
    }

    // Frontend: bun install
    if (feCount > 0) {
      const feDir = join(outputDir, feProjectName)
      logStep(`[FE] bun install`)
      const installFe = spawnSync('bun', ['install'], { cwd: feDir, stdio: ['pipe', 'pipe', 'pipe'] })
      if (installFe.status === 0) {
        logSuccess('bun install completed')
      } else {
        logError(`bun install failed: ${(installFe.stderr ?? '').toString().slice(0, 200)}`)
        process.exit(1)
      }
    }

    // ── Summary ──
    log(`\n${GREEN}${BOLD}╔══════════════════════════════════════════╗${RESET}`)
    log(`${GREEN}${BOLD}║   Project scaffolding complete!          ║${RESET}`)
    log(`${GREEN}${BOLD}╚══════════════════════════════════════════╝${RESET}`)

    log(`\n  ${BOLD}Generated structure:${RESET}`)
    log(`  ${outputDir}/`)
    log(`  ├── ${apiProjectName}/`)
    log(`  │   ├── src/config.json + drizzle/schema.ts`)
    log(`  │   ├── k8s/ (Dockerfile, configmap, deployment, service, pvc)`)
    log(`  │   ├── azure-pipelines.yml`)
    log(`  │   ├── .env + .dockerignore`)
    log(`  │   └── node_modules/ ${DIM}(installed)${RESET}`)
    if (feCount > 0) {
      log(`  └── ${feProjectName}/`)
      log(`      ├── app/ + lib/api/config.ts`)
      log(`      ├── k8s/ (Dockerfile, configmap, deployment, service, ingress)`)
      log(`      ├── azure-pipelines.yml`)
      log(`      ├── .env + .env.local + .dockerignore`)
      log(`      └── node_modules/ ${DIM}(installed)${RESET}`)
    }

    log(`\n  ${BOLD}Ready to run:${RESET}`)
    log(`  ${CYAN}cd ${beDir} && bun run dev${RESET}`)
    if (feCount > 0) {
      log(`  ${CYAN}cd ${join(outputDir, feProjectName)} && bun run dev${RESET}`)
    }
    log(`\n  ${DIM}Prerequisites: PostgreSQL + Redis running locally.`)
    log(`  For deployment, configure Azure DevOps pipeline variables.${RESET}\n`)
  } finally {
    rl.close()
  }
}

// Direct execution support (bun run generate-project.ts)
if (import.meta.main) {
  const scriptDir = resolve(import.meta.dir, '..')
  scaffold(scriptDir).catch((err: Error) => {
    logError(`Unexpected error: ${err}`)
    process.exit(1)
  })
}
