#!/usr/bin/env node
'use strict';

/**
 * CVE Dashboard — Unified Setup Script (Config Wizard)
 *
 * Single-file Node.js CLI that orchestrates the full setup lifecycle:
 *   1. Environment Configuration — interactive prompts with validation
 *   2. Postgres Provisioning — Docker container start + readiness wait
 *   3. Schema Initialization — execute db-schema.sql via psql or docker exec
 *   4. Dependency Installation — npm install in backend and frontend
 *   5. Data Migration — optional SQLite→Postgres migration
 *   6. Frontend Build — production bundle generation
 *
 * Zero npm dependencies — uses only Node.js built-in modules.
 *
 * Usage: node configure.js
 */

const readline = require('readline');
const { execSync, spawn } = require('child_process');
const fs = require('fs');
const path = require('path');

// ─────────────────────────────────────────────────────────────────────────────
// Constants & Configuration
// ─────────────────────────────────────────────────────────────────────────────

/**
 * Substrings that mark a variable name as sensitive (case-insensitive match).
 * Variables matching these patterns have their values masked in display.
 */
const SENSITIVE_PATTERNS = ['PASSWORD', 'PASS', 'SECRET', 'KEY', 'TOKEN', 'PAT'];

/**
 * Ordered list of variable group names. Variables are presented in this order
 * during the wizard flow.
 */
const GROUP_ORDER = [
  'Core Settings',
  'Database',
  'Session',
  'NVD API',
  'Ivanti Integration',
  'Atlas Integration',
  'Jira Integration',
  'CARD Integration',
  'GitLab Integration',
  'Frontend Settings'
];

/**
 * One-line description for each variable group (max 120 chars).
 */
const GROUP_DESCRIPTIONS = {
  'Core Settings': 'Server port, hostname, and CORS configuration',
  'Database': 'PostgreSQL connection string for persistent storage',
  'Session': 'Secret key for signing session cookies',
  'NVD API': 'National Vulnerability Database API key for CVE lookups',
  'Ivanti Integration': 'RiskSense platform credentials for vulnerability sync',
  'Atlas Integration': 'Atlas InfoSec API for action plan management',
  'Jira Integration': 'Jira Data Center for ticket creation and tracking',
  'CARD Integration': 'CARD asset ownership API for host lookups',
  'GitLab Integration': 'GitLab API for feedback submission (bug reports)',
  'Frontend Settings': 'React app API endpoint configuration'
};

/**
 * Groups that can be skipped entirely during the wizard flow.
 * The user is prompted with a yes/no question before entering these groups.
 */
const OPTIONAL_GROUPS = [
  'NVD API',
  'Ivanti Integration',
  'Atlas Integration',
  'Jira Integration',
  'CARD Integration',
  'GitLab Integration'
];

/**
 * Provisioning steps tracked for skip reporting in the completion banner.
 * Each step has a name and a manual command the user can run independently.
 */
const PROVISIONING_STEPS = [
  { name: 'Environment file creation', manualCmd: 'node configure.js' },
  { name: 'Postgres container startup', manualCmd: 'docker compose up -d' },
  { name: 'Database initialization', manualCmd: 'psql $DATABASE_URL -f backend/db-schema.sql' },
  { name: 'Dependency installation', manualCmd: 'cd backend && npm install --production && cd ../frontend && npm install' },
  { name: 'Frontend build', manualCmd: 'cd frontend && npm run build' }
];

/**
 * Complete registry of all managed environment variables.
 * Each descriptor defines metadata used for prompting, validation, and file generation.
 *
 * @typedef {Object} VariableDescriptor
 * @property {string} name - Environment variable name (e.g. "PORT")
 * @property {string} group - Variable group name (must be in GROUP_ORDER)
 * @property {string} target - Target env file: "backend" or "frontend"
 * @property {boolean} required - Whether the variable must have a non-empty value
 * @property {string|null} default - Factory default value, or null if none
 * @property {string} description - What the variable controls (max 120 chars)
 * @property {string|null} docUrl - URL or instruction for obtaining the value (max 200 chars)
 * @property {boolean} sensitive - Whether to mask the value in display
 * @property {string|null} validator - Name of validation function to apply, or null
 */

/**
 * @type {VariableDescriptor[]}
 */
const VARIABLE_DESCRIPTORS = [
  // ── Core Settings ──────────────────────────────────────────────────────────
  {
    name: 'PORT',
    group: 'Core Settings',
    target: 'backend',
    required: true,
    default: '3001',
    description: 'TCP port the Express server listens on',
    docUrl: null,
    sensitive: false,
    validator: 'validatePort'
  },
  {
    name: 'API_HOST',
    group: 'Core Settings',
    target: 'backend',
    required: true,
    default: 'localhost',
    description: 'Hostname or IP address the server binds to',
    docUrl: null,
    sensitive: false,
    validator: null
  },
  {
    name: 'CORS_ORIGINS',
    group: 'Core Settings',
    target: 'backend',
    required: true,
    default: null,
    description: 'Comma-separated list of allowed CORS origins',
    docUrl: null,
    sensitive: false,
    validator: 'validateCorsOrigins'
  },

  // ── Database ───────────────────────────────────────────────────────────────
  {
    name: 'DATABASE_URL',
    group: 'Database',
    target: 'backend',
    required: true,
    default: null,
    description: 'PostgreSQL connection string (postgresql://user:pass@host:port/db)',
    docUrl: null,
    sensitive: true,
    validator: 'validateDatabaseUrl'
  },

  // ── Session ────────────────────────────────────────────────────────────────
  {
    name: 'SESSION_SECRET',
    group: 'Session',
    target: 'backend',
    required: true,
    default: null,
    description: 'Secret key for signing session cookies (min 16 chars)',
    docUrl: null,
    sensitive: true,
    validator: 'validateSessionSecret'
  },

  // ── NVD API ────────────────────────────────────────────────────────────────
  {
    name: 'NVD_API_KEY',
    group: 'NVD API',
    target: 'backend',
    required: false,
    default: null,
    description: 'API key for NVD lookups (increases rate limit from 5 to 50 req/30s)',
    docUrl: 'Request at https://nvd.nist.gov/developers/request-an-api-key',
    sensitive: true,
    validator: null
  },

  // ── Ivanti Integration ─────────────────────────────────────────────────────
  {
    name: 'IVANTI_API_KEY',
    group: 'Ivanti Integration',
    target: 'backend',
    required: false,
    default: null,
    description: 'RiskSense API key for vulnerability data sync',
    docUrl: 'Obtain from RiskSense admin console under API Keys',
    sensitive: true,
    validator: null
  },
  {
    name: 'IVANTI_CLIENT_ID',
    group: 'Ivanti Integration',
    target: 'backend',
    required: false,
    default: '1550',
    description: 'RiskSense client ID for API requests',
    docUrl: 'Obtain from RiskSense admin console under API Keys',
    sensitive: false,
    validator: null
  },
  {
    name: 'IVANTI_FIRST_NAME',
    group: 'Ivanti Integration',
    target: 'backend',
    required: false,
    default: null,
    description: 'First name associated with the Ivanti service account',
    docUrl: 'Obtain from RiskSense admin console under API Keys',
    sensitive: false,
    validator: null
  },
  {
    name: 'IVANTI_LAST_NAME',
    group: 'Ivanti Integration',
    target: 'backend',
    required: false,
    default: null,
    description: 'Last name associated with the Ivanti service account',
    docUrl: 'Obtain from RiskSense admin console under API Keys',
    sensitive: false,
    validator: null
  },
  {
    name: 'IVANTI_BU_FILTER',
    group: 'Ivanti Integration',
    target: 'backend',
    required: false,
    default: 'NTS-AEO-ACCESS-ENG,NTS-AEO-STEAM',
    description: 'Comma-separated BU values to sync from Ivanti',
    docUrl: 'Obtain from RiskSense admin console under API Keys',
    sensitive: false,
    validator: null
  },
  {
    name: 'IVANTI_MANAGED_BUS',
    group: 'Ivanti Integration',
    target: 'backend',
    required: false,
    default: 'NTS-AEO-ACCESS-ENG,NTS-AEO-STEAM',
    description: 'Comma-separated BUs considered managed for drift classification',
    docUrl: 'Obtain from RiskSense admin console under API Keys',
    sensitive: false,
    validator: null
  },
  {
    name: 'IVANTI_SKIP_TLS',
    group: 'Ivanti Integration',
    target: 'backend',
    required: false,
    default: 'false',
    description: 'Set to true to disable TLS verification (behind SSL inspection proxy)',
    docUrl: 'Obtain from RiskSense admin console under API Keys',
    sensitive: false,
    validator: null
  },

  // ── Atlas Integration ──────────────────────────────────────────────────────
  {
    name: 'ATLAS_API_URL',
    group: 'Atlas Integration',
    target: 'backend',
    required: false,
    default: null,
    description: 'Base URL for the Atlas InfoSec API',
    docUrl: 'Contact InfoSec team for Atlas API credentials',
    sensitive: false,
    validator: null
  },
  {
    name: 'ATLAS_API_USER',
    group: 'Atlas Integration',
    target: 'backend',
    required: false,
    default: null,
    description: 'Service account username for Atlas Basic Auth',
    docUrl: 'Contact InfoSec team for Atlas API credentials',
    sensitive: false,
    validator: null
  },
  {
    name: 'ATLAS_API_PASS',
    group: 'Atlas Integration',
    target: 'backend',
    required: false,
    default: null,
    description: 'Service account password for Atlas Basic Auth',
    docUrl: 'Contact InfoSec team for Atlas API credentials',
    sensitive: true,
    validator: null
  },
  {
    name: 'ATLAS_SKIP_TLS',
    group: 'Atlas Integration',
    target: 'backend',
    required: false,
    default: 'false',
    description: 'Set to true to disable TLS verification (behind SSL inspection proxy)',
    docUrl: 'Contact InfoSec team for Atlas API credentials',
    sensitive: false,
    validator: null
  },

  // ── Jira Integration ───────────────────────────────────────────────────────
  {
    name: 'JIRA_BASE_URL',
    group: 'Jira Integration',
    target: 'backend',
    required: false,
    default: null,
    description: 'Base URL for Jira Data Center REST API',
    docUrl: 'Generate token at Jira > Profile > Personal Access Tokens',
    sensitive: false,
    validator: null
  },
  {
    name: 'JIRA_AUTH_METHOD',
    group: 'Jira Integration',
    target: 'backend',
    required: false,
    default: 'basic',
    description: 'Authentication method: basic (user+token) or pat (personal access token)',
    docUrl: 'Generate token at Jira > Profile > Personal Access Tokens',
    sensitive: false,
    validator: null
  },
  {
    name: 'JIRA_API_USER',
    group: 'Jira Integration',
    target: 'backend',
    required: false,
    default: null,
    description: 'Service account username for Jira Basic Auth',
    docUrl: 'Generate token at Jira > Profile > Personal Access Tokens',
    sensitive: false,
    validator: null
  },
  {
    name: 'JIRA_API_TOKEN',
    group: 'Jira Integration',
    target: 'backend',
    required: false,
    default: null,
    description: 'API token for Jira Basic Auth (paired with JIRA_API_USER)',
    docUrl: 'Generate token at Jira > Profile > Personal Access Tokens',
    sensitive: true,
    validator: null
  },
  {
    name: 'JIRA_PAT',
    group: 'Jira Integration',
    target: 'backend',
    required: false,
    default: null,
    description: 'Personal Access Token for Jira PAT auth (alternative to basic)',
    docUrl: 'Generate token at Jira > Profile > Personal Access Tokens',
    sensitive: true,
    validator: null
  },
  {
    name: 'JIRA_PROJECT_KEY',
    group: 'Jira Integration',
    target: 'backend',
    required: false,
    default: null,
    description: 'Default project key for creating Jira issues from the dashboard',
    docUrl: 'Generate token at Jira > Profile > Personal Access Tokens',
    sensitive: false,
    validator: null
  },
  {
    name: 'JIRA_ISSUE_TYPE',
    group: 'Jira Integration',
    target: 'backend',
    required: false,
    default: 'Task',
    description: 'Default issue type when creating Jira tickets',
    docUrl: 'Generate token at Jira > Profile > Personal Access Tokens',
    sensitive: false,
    validator: null
  },
  {
    name: 'JIRA_SKIP_TLS',
    group: 'Jira Integration',
    target: 'backend',
    required: false,
    default: 'false',
    description: 'Set to true to disable TLS verification (behind SSL inspection proxy)',
    docUrl: 'Generate token at Jira > Profile > Personal Access Tokens',
    sensitive: false,
    validator: null
  },

  // ── CARD Integration ───────────────────────────────────────────────────────
  {
    name: 'CARD_API_URL',
    group: 'CARD Integration',
    target: 'backend',
    required: false,
    default: null,
    description: 'Base URL for the CARD asset ownership API',
    docUrl: 'Contact CARD team for API credentials',
    sensitive: false,
    validator: null
  },
  {
    name: 'CARD_API_USER',
    group: 'CARD Integration',
    target: 'backend',
    required: false,
    default: null,
    description: 'Service account username for CARD OAuth token acquisition',
    docUrl: 'Contact CARD team for API credentials',
    sensitive: false,
    validator: null
  },
  {
    name: 'CARD_API_PASS',
    group: 'CARD Integration',
    target: 'backend',
    required: false,
    default: null,
    description: 'Service account password for CARD OAuth token acquisition',
    docUrl: 'Contact CARD team for API credentials',
    sensitive: true,
    validator: null
  },
  {
    name: 'CARD_SKIP_TLS',
    group: 'CARD Integration',
    target: 'backend',
    required: false,
    default: 'false',
    description: 'Set to true to disable TLS verification (behind SSL inspection proxy)',
    docUrl: 'Contact CARD team for API credentials',
    sensitive: false,
    validator: null
  },

  // ── GitLab Integration ─────────────────────────────────────────────────────
  {
    name: 'GITLAB_URL',
    group: 'GitLab Integration',
    target: 'backend',
    required: false,
    default: 'http://steam-gitlab.charterlab.com',
    description: 'Base URL for the GitLab instance',
    docUrl: 'Generate at GitLab > Settings > Access Tokens',
    sensitive: false,
    validator: null
  },
  {
    name: 'GITLAB_PROJECT_ID',
    group: 'GitLab Integration',
    target: 'backend',
    required: false,
    default: null,
    description: 'Numeric project ID for feedback issue creation',
    docUrl: 'Generate at GitLab > Settings > Access Tokens',
    sensitive: false,
    validator: null
  },
  {
    name: 'GITLAB_PAT',
    group: 'GitLab Integration',
    target: 'backend',
    required: false,
    default: null,
    description: 'Personal Access Token with api scope for GitLab',
    docUrl: 'Generate at GitLab > Settings > Access Tokens',
    sensitive: true,
    validator: null
  },

  // ── Frontend Settings ──────────────────────────────────────────────────────
  {
    name: 'REACT_APP_API_BASE',
    group: 'Frontend Settings',
    target: 'frontend',
    required: true,
    default: null,
    description: 'Full URL to the backend API endpoint (e.g. http://localhost:3001/api)',
    docUrl: null,
    sensitive: false,
    validator: null
  },
  {
    name: 'REACT_APP_API_HOST',
    group: 'Frontend Settings',
    target: 'frontend',
    required: true,
    default: null,
    description: 'Base URL of the backend server (e.g. http://localhost:3001)',
    docUrl: null,
    sensitive: false,
    validator: null
  }
];

// ─────────────────────────────────────────────────────────────────────────────
// JSDoc Type Definitions (Object Shapes)
// ─────────────────────────────────────────────────────────────────────────────

/**
 * Infrastructure state detected at startup.
 *
 * @typedef {Object} InfraState
 * @property {boolean} postgresRunning - steam-postgres container is active
 * @property {boolean} backendNodeModules - backend/node_modules/ exists
 * @property {boolean} frontendNodeModules - frontend/node_modules/ exists
 * @property {boolean} frontendBuildExists - frontend/build/index.html exists
 * @property {boolean} backendEnvExists - backend/.env exists
 * @property {boolean} frontendEnvExists - frontend/.env exists
 * @property {boolean} dockerAvailable - docker command found on PATH
 * @property {boolean} psqlAvailable - psql command found on PATH
 * @property {boolean} npmAvailable - npm command found on PATH
 * @property {boolean} sqliteDbExists - backend/cve_database.db exists
 * @property {boolean} schemaFileExists - backend/db-schema.sql exists
 */

/**
 * Configuration state accumulated during the wizard flow.
 *
 * @typedef {Object} ConfigState
 * @property {Map<string, string>} values - Variable name → entered value
 * @property {Set<string>} skippedGroups - Groups the user declined
 * @property {Map<string, string>} existingBackend - Parsed from existing backend/.env
 * @property {Map<string, string>} existingFrontend - Parsed from existing frontend/.env
 * @property {string[]} unmanagedBackend - Lines not matching managed vars
 * @property {string[]} unmanagedFrontend - Lines not matching managed vars
 * @property {Object} derivedDefaults - Computed defaults from infrastructure
 * @property {string|null} derivedDefaults.DATABASE_URL
 * @property {'compose'|'fallback'} derivedDefaults.databaseUrlSource
 * @property {string|null} derivedDefaults.REACT_APP_API_BASE
 * @property {string|null} derivedDefaults.REACT_APP_API_HOST
 * @property {string|null} derivedDefaults.CORS_ORIGINS
 */

/**
 * Result of the provisioning pipeline, tracking what happened.
 *
 * @typedef {Object} ProvisioningResult
 * @property {string[]} skippedSteps - Names of steps that were skipped
 * @property {boolean} postgresStartedThisRun - Whether Postgres was started in this run
 * @property {boolean} schemaApplied - Whether schema DDL was executed
 * @property {boolean} depsInstalled - Whether npm install completed
 * @property {boolean} migrationRan - Whether data migration was executed
 * @property {boolean} frontendBuilt - Whether frontend build was executed
 */

// ─────────────────────────────────────────────────────────────────────────────
// Preflight Functions
// ─────────────────────────────────────────────────────────────────────────────

/**
 * Verify Node.js >= 18. Exits with code 1 if the major version is below 18.
 */
function checkNodeVersion() {
  const versionStr = process.version; // e.g. "v18.17.0"
  const major = parseInt(versionStr.slice(1).split('.')[0], 10);
  if (major < 18) {
    console.error(`Error: Node.js >= 18 is required (detected ${versionStr}). Please upgrade.`);
    process.exit(1);
  }
}

/**
 * Verify that backend/ and frontend/ directories exist relative to CWD.
 * Exits with code 1 if either is missing.
 */
function checkProjectRoot() {
  const backendExists = fs.existsSync(path.join(process.cwd(), 'backend'));
  const frontendExists = fs.existsSync(path.join(process.cwd(), 'frontend'));
  if (!backendExists || !frontendExists) {
    console.error('Error: This script must be run from the project root (backend/ and frontend/ directories not found).');
    process.exit(1);
  }
}

/**
 * Check if a CLI command exists on PATH.
 * @param {string} cmd - Command name to check (e.g. "docker", "psql", "npm")
 * @returns {boolean} true if the command is available
 */
function checkCommandExists(cmd) {
  try {
    execSync(`command -v ${cmd}`, { stdio: 'pipe' });
    return true;
  } catch {
    return false;
  }
}

// ─────────────────────────────────────────────────────────────────────────────
// State Detection Functions
// ─────────────────────────────────────────────────────────────────────────────

/**
 * Check if the steam-postgres Docker container is currently running.
 * @returns {boolean} true if the container is listed in `docker ps`
 */
function detectPostgresRunning() {
  try {
    const output = execSync("docker ps --format '{{.Names}}'", { stdio: 'pipe', encoding: 'utf8' });
    const names = output.split('\n').map(line => line.trim());
    return names.includes('steam-postgres');
  } catch {
    return false;
  }
}

/**
 * Check if node_modules directories exist for backend and frontend.
 * @returns {{ backend: boolean, frontend: boolean }}
 */
function detectNodeModules() {
  return {
    backend: fs.existsSync(path.join(process.cwd(), 'backend', 'node_modules')),
    frontend: fs.existsSync(path.join(process.cwd(), 'frontend', 'node_modules'))
  };
}

/**
 * Check if the frontend production build exists.
 * @returns {boolean} true if frontend/build/index.html exists
 */
function detectFrontendBuild() {
  return fs.existsSync(path.join(process.cwd(), 'frontend', 'build', 'index.html'));
}

/**
 * Orchestrate all infrastructure detection into a single InfraState object.
 * @returns {InfraState}
 */
function detectInfraState() {
  const nodeModules = detectNodeModules();
  return {
    postgresRunning: detectPostgresRunning(),
    backendNodeModules: nodeModules.backend,
    frontendNodeModules: nodeModules.frontend,
    frontendBuildExists: detectFrontendBuild(),
    backendEnvExists: fs.existsSync(path.join(process.cwd(), 'backend', '.env')),
    frontendEnvExists: fs.existsSync(path.join(process.cwd(), 'frontend', '.env')),
    dockerAvailable: checkCommandExists('docker'),
    psqlAvailable: checkCommandExists('psql'),
    npmAvailable: checkCommandExists('npm'),
    sqliteDbExists: fs.existsSync(path.join(process.cwd(), 'backend', 'cve_database.db')),
    schemaFileExists: fs.existsSync(path.join(process.cwd(), 'backend', 'db-schema.sql'))
  };
}

// ─────────────────────────────────────────────────────────────────────────────
// Parsing Functions
// ─────────────────────────────────────────────────────────────────────────────

/**
 * Parse an existing .env file into managed and unmanaged entries.
 * Managed variables are those whose names appear in VARIABLE_DESCRIPTORS.
 *
 * @param {string} filePath - Path to the .env file
 * @returns {{ managed: Map<string, string>, unmanaged: string[] }}
 */
function parseEnvFile(filePath) {
  const managed = new Map();
  const unmanaged = [];

  if (!fs.existsSync(filePath)) {
    return { managed, unmanaged };
  }

  let content;
  try {
    content = fs.readFileSync(filePath, 'utf8');
  } catch {
    return { managed, unmanaged };
  }

  const managedNames = new Set(VARIABLE_DESCRIPTORS.map(d => d.name));
  const lines = content.split('\n');

  for (const line of lines) {
    const trimmed = line.trim();

    // Skip empty lines and comments
    if (trimmed === '' || trimmed.startsWith('#')) {
      continue;
    }

    // Split on first '=' only
    const eqIndex = trimmed.indexOf('=');
    if (eqIndex === -1) {
      unmanaged.push(line);
      continue;
    }

    const key = trimmed.substring(0, eqIndex).trim();
    let value = trimmed.substring(eqIndex + 1);

    // Strip surrounding quotes from value
    if ((value.startsWith('"') && value.endsWith('"')) ||
        (value.startsWith("'") && value.endsWith("'"))) {
      value = value.slice(1, -1);
    }

    if (managedNames.has(key)) {
      managed.set(key, value);
    } else {
      unmanaged.push(line);
    }
  }

  return { managed, unmanaged };
}

/**
 * Parse docker-compose.yml to extract Postgres configuration.
 * Uses a line-by-line state machine (no YAML dependency).
 *
 * @param {string} filePath - Path to docker-compose.yml
 * @returns {{ user: string, password: string, database: string, port: string } | null}
 */
function parseDockerCompose(filePath) {
  if (!fs.existsSync(filePath)) {
    return null;
  }

  let content;
  try {
    content = fs.readFileSync(filePath, 'utf8');
  } catch {
    return null;
  }

  const lines = content.split('\n');

  let inServices = false;
  let inPostgres = false;
  let inEnvironment = false;
  let inPorts = false;
  let postgresIndent = -1;
  let envIndent = -1;
  let portsIndent = -1;

  let user = null;
  let password = null;
  let database = null;
  let port = null;

  for (let i = 0; i < lines.length; i++) {
    const line = lines[i];
    const trimmed = line.trimEnd();
    const stripped = trimmed.trimStart();
    const indent = trimmed.length - stripped.length;

    // Detect services: top-level key
    if (stripped === 'services:' && indent === 0) {
      inServices = true;
      continue;
    }

    if (!inServices) continue;

    // Detect postgres service
    if (!inPostgres && stripped === 'postgres:' && indent > 0) {
      inPostgres = true;
      postgresIndent = indent;
      continue;
    }

    // If we're in postgres, check if we've left it (another service at same indent)
    if (inPostgres && indent <= postgresIndent && stripped.length > 0 && !stripped.startsWith('#')) {
      if (indent === postgresIndent && stripped !== 'postgres:') {
        break; // Left the postgres service block
      }
    }

    if (!inPostgres) continue;

    // Detect environment block within postgres
    if (stripped === 'environment:' && indent > postgresIndent) {
      inEnvironment = true;
      envIndent = indent;
      inPorts = false;
      continue;
    }

    // Detect ports block within postgres
    if (stripped === 'ports:' && indent > postgresIndent) {
      inPorts = true;
      portsIndent = indent;
      inEnvironment = false;
      continue;
    }

    // If we hit another key at the same level as environment/ports, exit those blocks
    if (inEnvironment && indent <= envIndent && stripped.length > 0 && !stripped.startsWith('#') && !stripped.startsWith('-')) {
      inEnvironment = false;
    }
    if (inPorts && indent <= portsIndent && stripped.length > 0 && !stripped.startsWith('#') && !stripped.startsWith('-')) {
      inPorts = false;
    }

    // Parse environment variables
    if (inEnvironment && indent > envIndent) {
      const envMatch = stripped.match(/^(\w+):\s*(.+)$/);
      if (envMatch) {
        const envKey = envMatch[1];
        const envValue = resolveShellDefault(envMatch[2].trim());
        if (envKey === 'POSTGRES_USER') user = envValue;
        else if (envKey === 'POSTGRES_PASSWORD') password = envValue;
        else if (envKey === 'POSTGRES_DB') database = envValue;
      }
    }

    // Parse ports mapping
    if (inPorts && stripped.startsWith('-')) {
      // Format: - "5433:5432" or - 5433:5432
      const portMatch = stripped.match(/^-\s*["']?(\d+):\d+["']?$/);
      if (portMatch) {
        port = portMatch[1];
      }
    }
  }

  if (user && password && database && port) {
    return { user, password, database, port };
  }

  return null;
}

/**
 * Resolve shell variable substitution syntax: ${VAR:-default}
 * Extracts the default value from the pattern. Strips surrounding quotes.
 *
 * @param {string} value - The raw value string
 * @returns {string} The resolved default or the original string (quotes stripped)
 */
function resolveShellDefault(value) {
  // Strip surrounding quotes first
  let v = value.replace(/^['"]|['"]$/g, '');
  const match = v.match(/\$\{[^:}]+:-([^}]+)\}/);
  return match ? match[1] : v;
}

/**
 * Compute derived default values from confirmed PORT, API_HOST, and compose parsing result.
 *
 * @param {string|number} port - The confirmed PORT value
 * @param {string} apiHost - The confirmed API_HOST value
 * @param {{ user: string, password: string, database: string, port: string } | null} composeResult - Parsed docker-compose result
 * @returns {{ DATABASE_URL: string, databaseUrlSource: 'compose'|'fallback', REACT_APP_API_BASE: string, REACT_APP_API_HOST: string, CORS_ORIGINS: string }}
 */
function computeDerivedDefaults(port, apiHost, composeResult) {
  const defaults = {};

  // DATABASE_URL from docker-compose or fallback
  if (composeResult) {
    defaults.DATABASE_URL = `postgresql://${composeResult.user}:${composeResult.password}@localhost:${composeResult.port}/${composeResult.database}`;
    defaults.databaseUrlSource = 'compose';
  } else {
    defaults.DATABASE_URL = 'postgresql://steam:sV4xmC9xAUCFop0ypxMVS056QgPqGrX@localhost:5433/cve_dashboard';
    defaults.databaseUrlSource = 'fallback';
  }

  // Frontend defaults derived from confirmed PORT and API_HOST
  defaults.REACT_APP_API_BASE = `http://${apiHost}:${port}/api`;
  defaults.REACT_APP_API_HOST = `http://${apiHost}:${port}`;
  defaults.CORS_ORIGINS = 'http://localhost:3000';

  return defaults;
}

// ─────────────────────────────────────────────────────────────────────────────
// Validation Functions
// ─────────────────────────────────────────────────────────────────────────────

/**
 * Validate a port number string.
 * Must be an integer in [1, 65535] with no leading zeros or decimal points.
 *
 * @param {string} value - The input value to validate
 * @returns {boolean} true if valid
 */
function validatePort(value) {
  const trimmed = value.trim();
  if (trimmed === '') return false;
  const parsed = parseInt(trimmed, 10);
  if (isNaN(parsed)) return false;
  // Reject leading zeros, floats, or non-numeric characters
  if (trimmed !== String(parsed)) return false;
  return parsed >= 1 && parsed <= 65535;
}

/**
 * Validate CORS origins string.
 * Must be a comma-separated list where each non-empty entry starts with http:// or https://
 * followed by at least one non-whitespace character.
 *
 * @param {string} value - The input value to validate
 * @returns {boolean} true if valid
 */
function validateCorsOrigins(value) {
  const entries = value.split(',')
    .map(entry => entry.trim())
    .filter(entry => entry.length > 0);

  if (entries.length === 0) return false;

  return entries.every(entry => /^https?:\/\/\S+/.test(entry));
}

/**
 * Validate a DATABASE_URL string.
 * Must start with "postgresql://" or equal "sqlite".
 *
 * @param {string} value - The input value to validate
 * @returns {boolean} true if valid
 */
function validateDatabaseUrl(value) {
  return value.startsWith('postgresql://') || value === 'sqlite';
}

/**
 * Validate a session secret string.
 * Must be between 16 and 256 characters (inclusive).
 *
 * @param {string} value - The input value to validate
 * @returns {boolean} true if valid
 */
function validateSessionSecret(value) {
  return value.length >= 16 && value.length <= 256;
}

/**
 * Validate that a value is not empty or whitespace-only.
 *
 * @param {string} value - The input value to validate
 * @returns {boolean} true if the trimmed value has length > 0
 */
function validateRequired(value) {
  return value.trim().length > 0;
}

// ─────────────────────────────────────────────────────────────────────────────
// Display Functions
// ─────────────────────────────────────────────────────────────────────────────

/**
 * Mask a sensitive value for display.
 * If value length <= 8, return unchanged. Otherwise show first 4 + **** + last 4.
 *
 * @param {string} name - Variable name (unused, kept for API consistency)
 * @param {string} value - The value to potentially mask
 * @returns {string} The masked or original value
 */
function maskSensitive(name, value) {
  if (value.length <= 8) return value;
  return value.slice(0, 4) + '****' + value.slice(-4);
}

/**
 * Print the welcome banner listing all 6 phases the script will perform.
 */
function printWelcome() {
  console.log('');
  console.log('╔══════════════════════════════════════════════════════════════╗');
  console.log('║          CVE Dashboard — Unified Setup Wizard               ║');
  console.log('╠══════════════════════════════════════════════════════════════╣');
  console.log('║  This wizard will guide you through:                        ║');
  console.log('║                                                             ║');
  console.log('║  1. Environment Configuration (interactive prompts)         ║');
  console.log('║  2. Postgres Provisioning (Docker container)                ║');
  console.log('║  3. Schema Initialization (database tables)                 ║');
  console.log('║  4. Dependency Installation (npm install)                   ║');
  console.log('║  5. Data Migration (SQLite to Postgres, if applicable)      ║');
  console.log('║  6. Frontend Build (production bundle)                      ║');
  console.log('║                                                             ║');
  console.log('║  Press Ctrl+C at any time to cancel without changes.        ║');
  console.log('╚══════════════════════════════════════════════════════════════╝');
  console.log('');
}

/**
 * Print a group header with name and description.
 *
 * @param {string} groupName - The group name to display
 * @param {string} description - One-line description of the group
 */
function printGroupHeader(groupName, description) {
  console.log('');
  console.log(`── ${groupName} ──`);
  console.log(`   ${description}`);
  console.log('');
}

/**
 * Print a summary of configured values grouped by target file.
 * Masks sensitive values, shows file status, lists skipped groups.
 *
 * @param {ConfigState} config - The accumulated configuration state
 * @param {InfraState} infraState - The detected infrastructure state
 */
function printSummary(config, infraState) {
  console.log('');
  console.log('═══════════════════════════════════════════════════════════════');
  console.log('  Configuration Summary');
  console.log('═══════════════════════════════════════════════════════════════');

  // Group by target file
  const targets = ['backend', 'frontend'];
  for (const target of targets) {
    const filePath = path.join(target, '.env');
    const exists = target === 'backend' ? infraState.backendEnvExists : infraState.frontendEnvExists;
    const status = exists ? '[EXISTS]' : '[NEW]';
    console.log('');
    console.log(`  ${filePath} ${status}`);
    console.log('  ' + '─'.repeat(50));

    const descriptors = VARIABLE_DESCRIPTORS.filter(d => d.target === target);
    for (const desc of descriptors) {
      if (config.skippedGroups.has(desc.group)) continue;
      const value = config.values.get(desc.name);
      if (value === undefined || value === '') continue;
      const displayValue = desc.sensitive ? '********' : value;
      console.log(`    ${desc.name}=${displayValue}`);
    }
  }

  // List skipped groups
  if (config.skippedGroups.size > 0) {
    console.log('');
    console.log('  Skipped Groups:');
    for (const group of config.skippedGroups) {
      console.log(`    ${group} [SKIPPED]`);
    }
  }

  console.log('');
  console.log('═══════════════════════════════════════════════════════════════');
  console.log('');
}

/**
 * Print the completion banner with next steps and skipped step commands.
 *
 * @param {number|string} port - The configured port number
 * @param {string[]} skippedSteps - Names of provisioning steps that were skipped
 * @param {boolean} postgresStarted - Whether Postgres was started this run
 */
function printCompletionBanner(port, skippedSteps, postgresStarted) {
  console.log('');
  console.log('╔══════════════════════════════════════════════════════════════╗');
  console.log('║                    Setup Complete!                           ║');
  console.log('╚══════════════════════════════════════════════════════════════╝');
  console.log('');
  console.log('  Next steps:');
  console.log(`    1. Start the server:  cd backend && node server.js`);
  console.log(`    2. Open in browser:   http://localhost:${port}`);
  console.log('    3. Create admin user: cd backend && node setup.js');
  console.log('');

  if (postgresStarted) {
    console.log('  Note: Postgres container was started with default restart policy.');
    console.log('  To persist across reboots: docker update --restart unless-stopped steam-postgres');
    console.log('');
  }

  if (skippedSteps.length > 0) {
    console.log('  Skipped steps (run manually if needed):');
    for (const stepName of skippedSteps) {
      const step = PROVISIONING_STEPS.find(s => s.name === stepName);
      if (step) {
        console.log(`    - ${step.name}: ${step.manualCmd}`);
      }
    }
    console.log('');
  }
}

// ─────────────────────────────────────────────────────────────────────────────
// Prompt Functions
// ─────────────────────────────────────────────────────────────────────────────

/**
 * Map of validator function names to their implementations.
 */
const VALIDATORS = {
  validatePort,
  validateCorsOrigins,
  validateDatabaseUrl,
  validateSessionSecret,
  validateRequired
};

/**
 * Prompt the user for a single variable value with validation and re-prompt on failure.
 *
 * @param {readline.Interface} rl - The readline interface
 * @param {VariableDescriptor} descriptor - Variable metadata
 * @param {string|undefined} currentValue - Value from existing .env file
 * @param {string|undefined} derivedDefault - Computed default from infrastructure
 * @returns {Promise<string>} The accepted value
 */
function promptVariable(rl, descriptor, currentValue, derivedDefault) {
  return new Promise((resolve) => {
    const label = descriptor.required ? '[REQUIRED]' : '[OPTIONAL]';
    const effectiveDefault = currentValue || derivedDefault || descriptor.default || '';
    const defaultSource = currentValue ? '[current]' : '';
    const displayDefault = descriptor.sensitive && effectiveDefault
      ? maskSensitive(descriptor.name, effectiveDefault)
      : effectiveDefault;

    const ask = () => {
      console.log(`  ${label} ${descriptor.name} — ${descriptor.description}`);
      if (descriptor.docUrl) {
        console.log(`    ${descriptor.docUrl}`);
      }
      const defaultHint = displayDefault
        ? ` (default: ${displayDefault}${defaultSource ? ' ' + defaultSource : ''})`
        : '';
      rl.question(`    Value${defaultHint}: `, (answer) => {
        const value = answer.trim() || effectiveDefault;

        if (descriptor.required && !value) {
          console.log('    Error: This field is required.');
          ask();
          return;
        }

        if (descriptor.validator && value) {
          const validatorFn = VALIDATORS[descriptor.validator];
          if (validatorFn && !validatorFn(value)) {
            console.log(`    Error: Invalid value for ${descriptor.name}. Please try again.`);
            ask();
            return;
          }
        }

        resolve(value);
      });
    };

    ask();
  });
}

/**
 * Prompt the user with a yes/no question.
 *
 * @param {readline.Interface} rl - The readline interface
 * @param {string} question - The question to display
 * @param {boolean} defaultYes - Whether the default answer is yes
 * @returns {Promise<boolean>} true for yes, false for no
 */
function promptYesNo(rl, question, defaultYes) {
  return new Promise((resolve) => {
    const hint = defaultYes ? '[Y/n]' : '[y/N]';

    const ask = () => {
      rl.question(`  ${question} ${hint}: `, (answer) => {
        const trimmed = answer.trim().toLowerCase();
        if (trimmed === '') {
          resolve(defaultYes);
        } else if (trimmed === 'y' || trimmed === 'yes') {
          resolve(true);
        } else if (trimmed === 'n' || trimmed === 'no') {
          resolve(false);
        } else {
          console.log('    Please answer y/yes or n/no.');
          ask();
        }
      });
    };

    ask();
  });
}

/**
 * Prompt the user about how to handle an existing file: overwrite, backup, or abort.
 *
 * @param {readline.Interface} rl - The readline interface
 * @param {string} filePath - Path to the existing file
 * @returns {Promise<string>} 'overwrite', 'backup', or 'abort'
 */
function promptOverwrite(rl, filePath) {
  return new Promise((resolve) => {
    const ask = () => {
      rl.question(`  ${filePath} already exists. (o)verwrite / (b)ackup / (a)bort? [b]: `, (answer) => {
        const trimmed = answer.trim().toLowerCase();
        if (trimmed === '' || trimmed === 'b' || trimmed === 'backup') {
          resolve('backup');
        } else if (trimmed === 'o' || trimmed === 'overwrite') {
          resolve('overwrite');
        } else if (trimmed === 'a' || trimmed === 'abort') {
          resolve('abort');
        } else {
          console.log('    Please answer o (overwrite), b (backup), or a (abort).');
          ask();
        }
      });
    };

    ask();
  });
}

/**
 * Prompt the user to continue or abort the current operation.
 *
 * @param {readline.Interface} rl - The readline interface
 * @param {string} context - Description of what will happen next
 * @returns {Promise<boolean>} true for continue, false for abort
 */
function promptContinueOrAbort(rl, context) {
  return new Promise((resolve) => {
    const ask = () => {
      rl.question(`  ${context} Continue or abort? [C/a]: `, (answer) => {
        const trimmed = answer.trim().toLowerCase();
        if (trimmed === '' || trimmed === 'c' || trimmed === 'continue') {
          resolve(true);
        } else if (trimmed === 'a' || trimmed === 'abort') {
          resolve(false);
        } else {
          console.log('    Please answer c (continue) or a (abort).');
          ask();
        }
      });
    };

    ask();
  });
}

// ─────────────────────────────────────────────────────────────────────────────
// File Generation & Writing
// ─────────────────────────────────────────────────────────────────────────────

/**
 * Generate .env file content from configured values.
 *
 * Produces output with:
 * - Group comment headers (# --- Group Name ---)
 * - KEY=value lines in group/descriptor order
 * - Values containing spaces, #, quote chars, $, or newlines wrapped in double quotes
 *   with internal double quotes escaped as \"
 * - Optional variables omitted if no value in map AND no default in descriptor
 * - Unmanaged lines appended under '# Custom Variables' header
 * - Trailing newline, LF line endings throughout
 *
 * @param {Map<string, string>} values - Variable name to value map
 * @param {string[]} groupOrder - Ordered list of group names
 * @param {VariableDescriptor[]} descriptors - All variable descriptors
 * @param {string[]} unmanagedLines - Lines to preserve from original file
 * @returns {string} The complete .env file content
 */
function generateEnvContent(values, groupOrder, descriptors, unmanagedLines) {
  const lines = [];

  for (const group of groupOrder) {
    const groupDescriptors = descriptors.filter(d => d.group === group);
    if (groupDescriptors.length === 0) continue;

    // Only include groups that have at least one variable with a value in the map
    // (skipped groups won't have any values in the map)
    const hasValues = groupDescriptors.some(d => {
      const val = values.get(d.name);
      return val !== undefined && val !== '';
    });
    if (!hasValues) continue;

    lines.push(`# --- ${group} ---`);

    for (const desc of groupDescriptors) {
      const value = values.get(desc.name);

      // Omit optional variables with no value in map AND no default in descriptor
      if (!desc.required && (value === undefined || value === '') && !desc.default) {
        continue;
      }

      // Resolve the effective value: use map value, fall back to descriptor default
      const effectiveValue = (value !== undefined && value !== '') ? value : (desc.default || '');

      // Skip if still no effective value (required var with no value and no default)
      if (effectiveValue === '') continue;

      // Determine if value needs quoting: spaces, #, quote chars, $, or newlines
      const needsQuoting = /[\s#"'$\n]/.test(effectiveValue);
      if (needsQuoting) {
        const escaped = effectiveValue.replace(/"/g, '\\"');
        lines.push(`${desc.name}="${escaped}"`);
      } else {
        lines.push(`${desc.name}=${effectiveValue}`);
      }
    }

    lines.push('');
  }

  // Append unmanaged lines under Custom Variables header
  // Filter out any lines whose key matches a managed variable name (deduplication)
  if (unmanagedLines && unmanagedLines.length > 0) {
    const managedNames = new Set(descriptors.map(d => d.name));
    const filtered = unmanagedLines.filter(line => {
      const trimmed = line.trim();
      if (trimmed === '' || trimmed.startsWith('#')) return true;
      const eqIndex = trimmed.indexOf('=');
      if (eqIndex === -1) return true;
      const key = trimmed.substring(0, eqIndex).trim();
      return !managedNames.has(key);
    });

    if (filtered.length > 0) {
      lines.push('# Custom Variables');
      for (const line of filtered) {
        lines.push(line);
      }
      lines.push('');
    }
  }

  return lines.join('\n') + '\n';
}

/**
 * Write content to an env file.
 *
 * @param {string} filePath - Path to write the file
 * @param {string} content - File content to write
 */
function writeEnvFile(filePath, content) {
  fs.writeFileSync(filePath, content, 'utf8');
}

/**
 * Create a backup of an existing file.
 * Format: {filename}.backup.{YYYYMMDD_HHmmss}
 * If that path already exists, tries .bak.1, .bak.2, ... up to .bak.10.
 *
 * @param {string} filePath - Path to the file to back up
 * @returns {string} The backup file path used
 */
function createBackup(filePath) {
  const now = new Date();
  const timestamp = now.getFullYear().toString() +
    String(now.getMonth() + 1).padStart(2, '0') +
    String(now.getDate()).padStart(2, '0') + '_' +
    String(now.getHours()).padStart(2, '0') +
    String(now.getMinutes()).padStart(2, '0') +
    String(now.getSeconds()).padStart(2, '0');

  const backupPath = `${filePath}.backup.${timestamp}`;

  if (!fs.existsSync(backupPath)) {
    fs.copyFileSync(filePath, backupPath);
    return backupPath;
  }

  // Primary backup path exists — try numeric suffixes
  for (let i = 1; i <= 10; i++) {
    const fallbackPath = `${filePath}.bak.${i}`;
    if (!fs.existsSync(fallbackPath)) {
      fs.copyFileSync(filePath, fallbackPath);
      return fallbackPath;
    }
  }

  // All slots exhausted — overwrite the last one
  const lastPath = `${filePath}.bak.10`;
  fs.copyFileSync(filePath, lastPath);
  return lastPath;
}

// ─────────────────────────────────────────────────────────────────────────────
// Provisioning Functions
// ─────────────────────────────────────────────────────────────────────────────

/**
 * Spawn a child process with a timeout.
 *
 * @param {string} command - The command to run
 * @param {string[]} args - Command arguments
 * @param {Object} opts - Spawn options (cwd, env, etc.)
 * @param {number} timeoutMs - Timeout in milliseconds
 * @returns {Promise<{code: number|null, stdout: string, stderr: string, timedOut: boolean}>}
 */
function runWithTimeout(command, args, opts, timeoutMs) {
  return new Promise((resolve) => {
    const proc = spawn(command, args, { ...opts, stdio: 'pipe' });
    let stdout = '';
    let stderr = '';
    let killed = false;

    const timer = setTimeout(() => {
      killed = true;
      proc.kill('SIGTERM');
    }, timeoutMs);

    proc.stdout.on('data', (d) => { stdout += d.toString(); });
    proc.stderr.on('data', (d) => { stderr += d.toString(); });

    proc.on('close', (code) => {
      clearTimeout(timer);
      resolve({ code: killed ? null : code, stdout, stderr, timedOut: killed });
    });

    proc.on('error', (err) => {
      clearTimeout(timer);
      resolve({ code: 1, stdout, stderr: stderr + err.message, timedOut: false });
    });
  });
}

/**
 * Wait for Postgres to become ready by polling pg_isready.
 *
 * @param {number} timeoutMs - Maximum time to wait in milliseconds
 * @returns {Promise<boolean>} true if ready, false if timeout exceeded
 */
function waitForPostgresReady(timeoutMs) {
  const start = Date.now();
  const interval = 2000;

  return new Promise((resolve) => {
    const poll = () => {
      try {
        execSync('docker exec steam-postgres pg_isready -U steam -d cve_dashboard', { stdio: 'pipe' });
        resolve(true);
      } catch {
        if (Date.now() - start >= timeoutMs) {
          resolve(false);
        } else {
          setTimeout(poll, interval);
        }
      }
    };
    poll();
  });
}

/**
 * Provision the Postgres container via Docker Compose.
 *
 * @param {readline.Interface} rl - The readline interface
 * @param {InfraState} infraState - Detected infrastructure state
 * @returns {Promise<{started: boolean, ready: boolean}>}
 */
async function provisionPostgres(rl, infraState) {
  if (infraState.postgresRunning) {
    console.log('  Postgres container (steam-postgres) is already running.');
    return { started: false, ready: true };
  }

  if (!infraState.dockerAvailable) {
    console.log('  Error: Docker is not available. Cannot start Postgres container.');
    return { started: false, ready: false };
  }

  const shouldStart = await promptYesNo(rl, 'Start Postgres container (docker compose up -d)?', true);
  if (!shouldStart) {
    return { started: false, ready: false };
  }

  console.log('  Starting Postgres container...');
  try {
    const output = execSync('docker compose up -d', {
      cwd: process.cwd(),
      encoding: 'utf8',
      stdio: 'pipe'
    });
    if (output.trim()) console.log(output.trim());
  } catch (err) {
    console.error('  Error starting Postgres:', err.stderr || err.message);
    const cont = await promptContinueOrAbort(rl, 'Postgres failed to start.');
    if (!cont) return { started: false, ready: false };
  }

  console.log('  Waiting for Postgres to become ready...');
  const ready = await waitForPostgresReady(30000);
  if (!ready) {
    console.log('  Warning: Postgres did not become ready within 30 seconds.');
  } else {
    console.log('  Postgres is ready.');
  }

  return { started: true, ready };
}

/**
 * Execute the database schema DDL against Postgres.
 *
 * @param {string} databaseUrl - The DATABASE_URL connection string
 * @param {InfraState} infraState - Detected infrastructure state
 * @returns {Promise<boolean>} true on success
 */
async function executeSchema(databaseUrl, infraState) {
  const schemaPath = path.join(process.cwd(), 'backend', 'db-schema.sql');
  if (!fs.existsSync(schemaPath)) {
    console.log('  Error: backend/db-schema.sql not found. Skipping schema initialization.');
    return false;
  }

  console.log('  Applying database schema...');

  try {
    if (infraState.psqlAvailable) {
      execSync(`psql "${databaseUrl}" -f backend/db-schema.sql`, {
        cwd: process.cwd(),
        stdio: 'pipe',
        encoding: 'utf8'
      });
    } else {
      // Fallback to docker exec
      execSync('docker exec -i steam-postgres psql -U steam -d cve_dashboard < backend/db-schema.sql', {
        cwd: process.cwd(),
        stdio: 'pipe',
        encoding: 'utf8',
        shell: true
      });
    }
    console.log('  Schema applied successfully.');
    return true;
  } catch (err) {
    console.error('  Error applying schema:', err.stderr || err.message);
    return false;
  }
}

/**
 * Install npm dependencies in backend and frontend.
 *
 * @param {readline.Interface} rl - The readline interface
 * @param {InfraState} infraState - Detected infrastructure state
 * @returns {Promise<boolean>} true on success
 */
async function installDependencies(rl, infraState) {
  if (!infraState.npmAvailable) {
    console.log('  Error: npm is not available. Cannot install dependencies.');
    return false;
  }

  // Check if both node_modules exist
  if (infraState.backendNodeModules && infraState.frontendNodeModules) {
    const reinstall = await promptYesNo(rl, 'Dependencies already installed. Reinstall?', false);
    if (!reinstall) {
      console.log('  Skipping dependency installation.');
      return true;
    }
  }

  // Install backend dependencies
  if (!infraState.backendNodeModules || !(infraState.backendNodeModules && infraState.frontendNodeModules)) {
    console.log('  Installing backend dependencies...');
    const backendResult = await runWithTimeout('npm', ['install', '--production'], {
      cwd: path.join(process.cwd(), 'backend'),
      env: process.env
    }, 120000);

    if (backendResult.timedOut) {
      console.log('  Error: Backend npm install timed out after 120 seconds.');
      const cont = await promptContinueOrAbort(rl, 'Backend dependency installation failed.');
      if (!cont) return false;
    } else if (backendResult.code !== 0) {
      const output = (backendResult.stdout + backendResult.stderr).split('\n').slice(-50).join('\n');
      console.log('  Error installing backend dependencies:');
      console.log(output);
      const cont = await promptContinueOrAbort(rl, 'Backend dependency installation failed.');
      if (!cont) return false;
    } else {
      console.log('  Backend dependencies installed.');
    }
  }

  // Install frontend dependencies
  if (!infraState.frontendNodeModules || !(infraState.backendNodeModules && infraState.frontendNodeModules)) {
    console.log('  Installing frontend dependencies...');
    const frontendResult = await runWithTimeout('npm', ['install'], {
      cwd: path.join(process.cwd(), 'frontend'),
      env: process.env
    }, 120000);

    if (frontendResult.timedOut) {
      console.log('  Error: Frontend npm install timed out after 120 seconds.');
      const cont = await promptContinueOrAbort(rl, 'Frontend dependency installation failed.');
      if (!cont) return false;
    } else if (frontendResult.code !== 0) {
      const output = (frontendResult.stdout + frontendResult.stderr).split('\n').slice(-50).join('\n');
      console.log('  Error installing frontend dependencies:');
      console.log(output);
      const cont = await promptContinueOrAbort(rl, 'Frontend dependency installation failed.');
      if (!cont) return false;
    } else {
      console.log('  Frontend dependencies installed.');
    }
  }

  return true;
}

/**
 * Migrate data from SQLite to Postgres.
 *
 * @param {readline.Interface} rl - The readline interface
 * @param {string} databaseUrl - The DATABASE_URL connection string
 * @returns {Promise<boolean>} true on success or skip
 */
async function migrateData(rl, databaseUrl) {
  const sqliteDbPath = path.join(process.cwd(), 'backend', 'cve_database.db');
  if (!fs.existsSync(sqliteDbPath)) {
    console.log('  No legacy SQLite database found. Skipping migration.');
    return true;
  }

  const shouldMigrate = await promptYesNo(rl, 'Legacy SQLite database found. Migrate data to Postgres?', true);
  if (!shouldMigrate) {
    return true;
  }

  if (!databaseUrl) {
    console.log('  Error: DATABASE_URL is not set. Cannot migrate.');
    return false;
  }

  const maxRetries = 3;
  for (let attempt = 1; attempt <= maxRetries; attempt++) {
    console.log(`  Running migration (attempt ${attempt}/${maxRetries})...`);
    const result = await runWithTimeout('node', ['backend/scripts/migrate-to-postgres.js'], {
      cwd: process.cwd(),
      env: { ...process.env, DATABASE_URL: databaseUrl }
    }, 300000);

    if (result.code === 0) {
      console.log('  Migration completed successfully.');
      return true;
    }

    const output = (result.stdout + result.stderr).split('\n').slice(-200).join('\n');
    console.error(`  Migration attempt ${attempt}/${maxRetries} failed.`);
    if (output.trim()) console.log(output);

    if (attempt < maxRetries) {
      const retry = await promptYesNo(rl, 'Retry with schema reset?', true);
      if (!retry) return false;

      // Reset schema
      console.log('  Resetting schema...');
      try {
        const resetCmd = 'DROP SCHEMA public CASCADE; CREATE SCHEMA public;';
        if (checkCommandExists('psql')) {
          execSync(`psql "${databaseUrl}" -c "${resetCmd}"`, { stdio: 'pipe' });
        } else {
          execSync(`docker exec -i steam-postgres psql -U steam -d cve_dashboard -c "${resetCmd}"`, { stdio: 'pipe', shell: true });
        }
        // Re-apply schema
        await executeSchema(databaseUrl, { psqlAvailable: checkCommandExists('psql') });
      } catch (err) {
        console.error('  Error resetting schema:', err.message);
      }
    }
  }

  console.log('  Migration failed after all retries.');
  return false;
}

/**
 * Determine if the frontend build can be skipped.
 * Returns true only if old env existed and all REACT_APP_* values are unchanged.
 *
 * @param {Map<string, string>|null} oldFrontendEnv - Previous frontend env values
 * @param {Map<string, string>} newFrontendEnv - New frontend env values
 * @returns {boolean} true if build can be skipped
 */
function shouldSkipFrontendBuild(oldFrontendEnv, newFrontendEnv) {
  if (!oldFrontendEnv) return false;

  const reactAppKeys = [...newFrontendEnv.keys()].filter(k => k.startsWith('REACT_APP_'));
  return reactAppKeys.every(key => oldFrontendEnv.get(key) === newFrontendEnv.get(key));
}

/**
 * Build the frontend production bundle.
 *
 * @param {readline.Interface} rl - The readline interface
 * @param {InfraState} infraState - Detected infrastructure state
 * @param {Map<string, string>|null} oldFrontendEnv - Previous frontend env values
 * @param {Map<string, string>} newFrontendEnv - New frontend env values
 * @returns {Promise<boolean>} true on success or skip
 */
async function buildFrontend(rl, infraState, oldFrontendEnv, newFrontendEnv) {
  if (shouldSkipFrontendBuild(oldFrontendEnv, newFrontendEnv)) {
    console.log('  Frontend REACT_APP_* values unchanged. Skipping build.');
    return true;
  }

  console.log('  Building frontend production bundle...');
  const result = await runWithTimeout('npm', ['run', 'build'], {
    cwd: path.join(process.cwd(), 'frontend'),
    env: process.env
  }, 300000);

  if (result.timedOut) {
    console.log('  Error: Frontend build timed out after 300 seconds.');
    return false;
  }

  if (result.code !== 0) {
    const output = (result.stdout + result.stderr).split('\n').slice(-50).join('\n');
    console.log('  Error building frontend:');
    console.log(output);
    return false;
  }

  console.log('  Frontend build completed successfully.');
  return true;
}

// ─────────────────────────────────────────────────────────────────────────────
// Main Flow Orchestration
// ─────────────────────────────────────────────────────────────────────────────

/**
 * Main entry point — orchestrates all phases of the setup wizard.
 */
async function main() {
  // Phase 0: Preflight checks
  checkNodeVersion();
  checkProjectRoot();

  // Register SIGINT handler early to handle Ctrl+C gracefully at any point
  let rl = null;
  process.on('SIGINT', () => {
    console.log('\n\n  Setup cancelled. No files were written.');
    if (rl) rl.close();
    process.exit(1);
  });

  // Phase 1: State detection
  const infraState = detectInfraState();

  // Parse existing env files
  const backendEnvPath = path.join(process.cwd(), 'backend', '.env');
  const frontendEnvPath = path.join(process.cwd(), 'frontend', '.env');
  const existingBackend = parseEnvFile(backendEnvPath);
  const existingFrontend = parseEnvFile(frontendEnvPath);

  // Parse docker-compose.yml for defaults
  const composePath = path.join(process.cwd(), 'docker-compose.yml');
  const composeResult = parseDockerCompose(composePath);

  // Create readline interface
  rl = readline.createInterface({
    input: process.stdin,
    output: process.stdout
  });

  // Phase 2: Interactive configuration (wrapped in loop for restart on rejection)
  printWelcome();

  let config;
  let confirmed = false;

  while (!confirmed) {
    config = {
      values: new Map(),
      skippedGroups: new Set(),
      existingBackend: existingBackend.managed,
      existingFrontend: existingFrontend.managed,
      unmanagedBackend: existingBackend.unmanaged,
      unmanagedFrontend: existingFrontend.unmanaged,
      derivedDefaults: {}
    };

    let derivedDefaults = {};

    for (const group of GROUP_ORDER) {
      // Prompt to skip optional groups
      if (OPTIONAL_GROUPS.includes(group)) {
        const configure = await promptYesNo(rl, `Configure ${group}?`, false);
        if (!configure) {
          config.skippedGroups.add(group);
          continue;
        }
      }

      const description = GROUP_DESCRIPTIONS[group] || '';
      printGroupHeader(group, description);

      const groupDescriptors = VARIABLE_DESCRIPTORS.filter(d => d.group === group);

      for (const desc of groupDescriptors) {
        // Determine current value from existing env
        const currentValue = desc.target === 'backend'
          ? existingBackend.managed.get(desc.name)
          : existingFrontend.managed.get(desc.name);

        // Determine derived default for DATABASE_URL, CORS_ORIGINS, REACT_APP_API_BASE, REACT_APP_API_HOST
        const derivedDefault = derivedDefaults[desc.name] || undefined;

        const value = await promptVariable(rl, desc, currentValue, derivedDefault);
        if (value) {
          config.values.set(desc.name, value);
        }
      }

      // Compute derived defaults after Core Settings group (PORT, API_HOST confirmed)
      if (group === 'Core Settings') {
        const port = config.values.get('PORT') || '3001';
        const apiHost = config.values.get('API_HOST') || 'localhost';
        derivedDefaults = computeDerivedDefaults(port, apiHost, composeResult);
        config.derivedDefaults = derivedDefaults;
      }
    }

    // Phase 3: Summary and confirmation
    printSummary(config, infraState);

    confirmed = await promptYesNo(rl, 'Apply this configuration?', true);
    if (!confirmed) {
      const restart = await promptYesNo(rl, 'Restart configuration from the beginning?', true);
      if (!restart) {
        console.log('  Configuration cancelled.');
        rl.close();
        process.exit(1);
      }
      // Loop continues — restart configuration
      console.log('');
      console.log('  Restarting configuration...');
      console.log('');
    }
  }

  // Phase 4: Write env files
  const skippedSteps = [];

  // Prepare backend env values
  const backendValues = new Map();
  const frontendValues = new Map();
  for (const [name, value] of config.values) {
    const desc = VARIABLE_DESCRIPTORS.find(d => d.name === name);
    if (desc) {
      if (desc.target === 'backend') backendValues.set(name, value);
      else frontendValues.set(name, value);
    }
  }

  const backendDescriptors = VARIABLE_DESCRIPTORS.filter(d => d.target === 'backend');
  const frontendDescriptors = VARIABLE_DESCRIPTORS.filter(d => d.target === 'frontend');

  // Write backend .env
  if (infraState.backendEnvExists) {
    const action = await promptOverwrite(rl, backendEnvPath);
    if (action === 'abort') {
      console.log('  Aborted.');
      rl.close();
      process.exit(1);
    }
    if (action === 'backup') {
      const backupPath = createBackup(backendEnvPath);
      console.log(`  Backup created: ${backupPath}`);
    }
  }
  const backendContent = generateEnvContent(backendValues, GROUP_ORDER, backendDescriptors, config.unmanagedBackend);
  writeEnvFile(backendEnvPath, backendContent);
  console.log(`  Written: ${backendEnvPath}`);

  // Write frontend .env
  if (infraState.frontendEnvExists) {
    const action = await promptOverwrite(rl, frontendEnvPath);
    if (action === 'abort') {
      console.log('  Aborted.');
      rl.close();
      process.exit(1);
    }
    if (action === 'backup') {
      const backupPath = createBackup(frontendEnvPath);
      console.log(`  Backup created: ${backupPath}`);
    }
  }
  const frontendContent = generateEnvContent(frontendValues, GROUP_ORDER, frontendDescriptors, config.unmanagedFrontend);
  writeEnvFile(frontendEnvPath, frontendContent);
  console.log(`  Written: ${frontendEnvPath}`);

  // Phase 5: Provisioning pipeline
  console.log('');
  console.log('── Provisioning Pipeline ──');
  console.log('');

  let postgresStarted = false;

  // Postgres provisioning
  const pgResult = await provisionPostgres(rl, infraState);
  postgresStarted = pgResult.started;
  if (!pgResult.ready) {
    skippedSteps.push('Postgres container startup');
  }

  // Schema initialization
  if (pgResult.ready) {
    const databaseUrl = config.values.get('DATABASE_URL') || '';
    const schemaOk = await executeSchema(databaseUrl, infraState);
    if (!schemaOk) {
      skippedSteps.push('Database initialization');
    }
  } else {
    skippedSteps.push('Database initialization');
  }

  // Dependency installation
  const depsOk = await installDependencies(rl, infraState);
  if (!depsOk) {
    skippedSteps.push('Dependency installation');
  }

  // Data migration
  const databaseUrl = config.values.get('DATABASE_URL') || '';
  if (pgResult.ready && databaseUrl) {
    const migrationOk = await migrateData(rl, databaseUrl);
    if (!migrationOk) {
      skippedSteps.push('Data migration');
    }
  }

  // Frontend build
  const oldFrontendEnv = infraState.frontendEnvExists ? existingFrontend.managed : null;
  const buildOk = await buildFrontend(rl, infraState, oldFrontendEnv, frontendValues);
  if (!buildOk) {
    skippedSteps.push('Frontend build');
  }

  // Phase 6: Completion
  const port = config.values.get('PORT') || '3001';
  printCompletionBanner(port, skippedSteps, postgresStarted);

  rl.close();
  process.exit(0);
}

// ─────────────────────────────────────────────────────────────────────────────
// Module Exports (for testability)
// ─────────────────────────────────────────────────────────────────────────────

if (require.main !== module) {
  module.exports = {
    VARIABLE_DESCRIPTORS,
    GROUP_ORDER,
    GROUP_DESCRIPTIONS,
    OPTIONAL_GROUPS,
    SENSITIVE_PATTERNS,
    PROVISIONING_STEPS,
    // Preflight
    checkNodeVersion,
    checkProjectRoot,
    checkCommandExists,
    // State Detection
    detectPostgresRunning,
    detectNodeModules,
    detectFrontendBuild,
    detectInfraState,
    // Parsing
    parseEnvFile,
    parseDockerCompose,
    resolveShellDefault,
    computeDerivedDefaults,
    // Validation
    validatePort,
    validateCorsOrigins,
    validateDatabaseUrl,
    validateSessionSecret,
    validateRequired,
    // Display
    maskSensitive,
    printWelcome,
    printGroupHeader,
    printSummary,
    printCompletionBanner,
    // Prompt
    promptVariable,
    promptYesNo,
    promptOverwrite,
    promptContinueOrAbort,
    // File Generation
    generateEnvContent,
    writeEnvFile,
    createBackup,
    // Provisioning
    runWithTimeout,
    waitForPostgresReady,
    provisionPostgres,
    executeSchema,
    installDependencies,
    migrateData,
    shouldSkipFrontendBuild,
    buildFrontend,
    // Main
    main
  };
}

if (require.main === module) {
  main().catch(err => {
    console.error(`Error: ${err.message}`);
    process.exit(1);
  });
}
