const { exec } = require('node:child_process');
const { promisify } = require('node:util');
const path = require('node:path');
const os = require('node:os');
const fs = require('fs-extra');
const pkg = require('../../package.json');
const kleur = require('kleur');
const ui = require('../utils/ui');
const { printCompactHeader } = require('../utils/brand');
const { createTranslator, detectDefaultLanguage } = require('../utils/i18n');
const {
  AVAILABLE_FEATURES,
  FEATURES_PATCH_DIR,
  normalizeFeature,
  getVisibleFeatures,
  getBaseFeatures,
  BASE_FEATURES,
} = require('../scaffold/catalog');

function findBaseDisplayName(id) {
  const base = BASE_FEATURES.find((f) => f.id === id);
  return base ? base.displayName : id;
}

const KASY_AUDIENCE = process.env.KASY_INTERNAL === '1' ? 'internal' : 'public';
const { applyPatch } = require('../scaffold/engine');
const { writeRouter, writeNoOpAnalyticsApi, writeNoOpTrackingApi, writeNoOpAdminHomeWidgets, writeNoOpFeatureRequestRepository, writeMainDart, addPubspecDep, localizeReleaseDocs, resolveDefaultRcKeys } = require('../scaffold/shared/generator-utils');
const { toPackageName, buildTokens } = require('../scaffold/backends/firebase/tokens');

const execAsync = promisify(exec);

/** URL do endpoint LLM no app (espelha defineUpdates de llm_chat). */
function resolveLlmChatEndpoint(answers, kitSetup) {
  if (kitSetup?.backendProvider === 'api') {
    return answers.llmEndpoint || 'YOUR_LLM_ENDPOINT';
  }
  if (kitSetup?.backendProvider === 'supabase') {
    const ref = kitSetup.supabaseProjectId || 'YOUR_PROJECT_REF';
    return `https://${ref}.supabase.co/functions/v1/llm-chat`;
  }
  const projectId = kitSetup?.firebaseProjectId || 'YOUR_PROJECT_ID';
  const region = kitSetup?.functionsRegion || 'us-central1';
  return `https://${region}-${projectId}.cloudfunctions.net/llmChat`;
}

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

/**
 * Reconstruct active modules from kit_setup.json + features.dart.
 */
async function getActiveModules(kitSetup, projectDir) {
  const modules = [];
  if (kitSetup.useSentry) modules.push('sentry');
  if (kitSetup.analyticsProvider === 'mixpanel') modules.push('analytics');
  if (kitSetup.withFacebookPixel) modules.push('facebook');
  if (kitSetup.subscriptionModule) modules.push('revenuecat');
  if (kitSetup.withOnboarding) modules.push('onboarding');
  if (kitSetup.webCompat) modules.push('web');
  if (kitSetup.feedbackModule) modules.push('feedback');

  const featuresPath = path.join(projectDir, 'lib', 'core', 'config', 'features.dart');
  if (await fs.pathExists(featuresPath)) {
    const content = await fs.readFile(featuresPath, 'utf8');
    if (/const bool withLlmChat\s*=\s*true/.test(content)) modules.push('llm_chat');
  }

  if (await fs.pathExists(path.join(projectDir, '.github', 'workflows'))) modules.push('ci');
  if (await fs.pathExists(path.join(projectDir, 'lib', 'core', 'home_widgets', 'home_widget_service.dart'))) modules.push('widget');

  return modules;
}

/**
 * Toggle a single feature flag in features.dart (false → true).
 * Only touches the specific flag — does not rewrite the whole file.
 */
async function enableFeatureFlag(projectDir, flag) {
  const featuresPath = path.join(projectDir, 'lib', 'core', 'config', 'features.dart');
  if (!(await fs.pathExists(featuresPath))) return;
  let content = await fs.readFile(featuresPath, 'utf8');
  content = content.replace(
    new RegExp(`(const bool ${flag}\\s*=\\s*)false`, 'g'),
    '$1true'
  );
  await fs.outputFile(featuresPath, content, 'utf8');
}

/**
 * Add or update a dart-define in a launch.json args array.
 * Preserves all other args. If the key already exists, replaces the value.
 */
function upsertDartDefine(args, key, value) {
  const prefix = `--dart-define=${key}=`;
  const filtered = args.filter((a) => !a.startsWith(prefix));
  filtered.push(`${prefix}${value}`);
  return filtered;
}

/**
 * Add or update dart-define entries in the project Makefile.
 * Only touches lines inside the DEFINES block; leaves everything else intact.
 * If no DEFINES block exists (old project), appends one before .PHONY.
 */
async function ensureIosReleaseMakefile(projectDir) {
  const makePath = path.join(projectDir, 'Makefile');
  if (!(await fs.pathExists(makePath))) return;
  let content = await fs.readFile(makePath, 'utf8');
  if (content.includes('release-ios')) return;
  content += `\n.PHONY: release-ios\nrelease-ios:\n\tbash scripts/release-ios.sh\n`;
  await fs.outputFile(makePath, content, 'utf8');
}

async function ensureGitignoreKasyEnv(projectDir) {
  const giPath = path.join(projectDir, '.gitignore');
  if (!(await fs.pathExists(giPath))) return;
  let content = await fs.readFile(giPath, 'utf8');
  for (const line of ['.kasy/apple.env', '.kasy/codemagic.env']) {
    if (!content.includes(line)) {
      content += `\n${line}\n`;
    }
  }
  await fs.outputFile(giPath, content, 'utf8');
}

async function updateMakefile(projectDir, defineUpdates) {
  const makePath = path.join(projectDir, 'Makefile');
  if (!(await fs.pathExists(makePath))) return;
  let content = await fs.readFile(makePath, 'utf8');

  for (const [key, value] of Object.entries(defineUpdates)) {
    const flag = `--dart-define=${key}=`;
    const lineRegex = new RegExp(`(  ${flag.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})\\S*( \\\\)?`, 'g');
    if (lineRegex.test(content)) {
      // Replace existing value
      content = content.replace(
        new RegExp(`(  ${flag.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})\\S*( \\\\)?`, 'g'),
        (_, prefix, suffix) => `${prefix}${value}${suffix || ''}`
      );
    } else if (content.includes('DEFINES :=')) {
      // Append inside existing DEFINES block — find last define line and add after
      content = content.replace(
        /(DEFINES :=[\s\S]*?)(\n\n\.PHONY)/,
        (_, block, rest) => {
          const trimmed = block.trimEnd().replace(/ \\$/, '');
          return `${trimmed} \\\n  ${flag}${value}${rest}`;
        }
      );
    } else {
      // No DEFINES block yet — insert before .PHONY
      content = content.replace(
        /^(\.PHONY:)/m,
        `DEFINES := \\\n  ${flag}${value}\n\n$1`
      );
    }
  }

  await fs.outputFile(makePath, content, 'utf8');
}

/**
 * Update .vscode/launch.json by adding/updating dart-defines for a module.
 * Applies the same change to both dev and prod configurations.
 */
async function updateLaunchJson(projectDir, defineUpdates) {
  const launchPath = path.join(projectDir, '.vscode', 'launch.json');
  if (!(await fs.pathExists(launchPath))) return;
  const launch = await fs.readJson(launchPath);

  for (const config of launch.configurations || []) {
    if (!Array.isArray(config.args)) continue;
    for (const [key, value] of Object.entries(defineUpdates)) {
      config.args = upsertDartDefine(config.args, key, value);
    }
  }

  await fs.outputFile(launchPath, JSON.stringify(launch, null, 2) + '\n', 'utf8');
}

/**
 * Append module vars to .env.example (only if not already present).
 */
async function appendEnvExample(projectDir, lines) {
  const envPath = path.join(projectDir, '.env.example');
  if (!(await fs.pathExists(envPath))) return;
  const existing = await fs.readFile(envPath, 'utf8');
  const toAdd = lines.filter((l) => !existing.includes(l.split('=')[0]));
  if (toAdd.length === 0) return;
  await fs.appendFile(envPath, '\n' + toAdd.join('\n') + '\n', 'utf8');
}

/**
 * Append module vars to .env (only if file exists and key is missing).
 */
async function appendEnvFile(projectDir, lines) {
  const envPath = path.join(projectDir, '.env');
  if (!(await fs.pathExists(envPath))) return;
  const existing = await fs.readFile(envPath, 'utf8');
  const toAdd = lines.filter((l) => !existing.includes(l.split('=')[0]));
  if (toAdd.length === 0) return;
  await fs.appendFile(envPath, '\n' + toAdd.join('\n') + '\n', 'utf8');
}

/**
 * Update kit_setup.json flags for the newly added module.
 */
function applyKitSetupFlag(config, module, answers) {
  switch (module) {
    case 'sentry':
      config.useSentry = true;
      break;
    case 'analytics':
      config.analyticsProvider = 'mixpanel';
      break;
    case 'facebook':
      config.withFacebookPixel = true;
      break;
    case 'revenuecat':
      config.subscriptionModule = true;
      if (answers.revenuecatWeb) config.revenuecatWeb = true;
      // Track which RC keys the user configured so `kasy doctor` can warn
      // about release readiness without re-reading .env. Booleans only — we
      // never persist the key values themselves to kit_setup.json.
      config.revenuecatKeys = {
        test: !!(answers.rcTestKey && answers.rcTestKey.trim()),
        iosProd: !!(answers.rcIosProdKey && answers.rcIosProdKey.trim()),
        androidProd: !!(answers.rcAndroidProdKey && answers.rcAndroidProdKey.trim()),
      };
      break;
    case 'onboarding':
      config.withOnboarding = true;
      break;
    case 'web':
      config.webCompat = true;
      break;
    case 'feedback':
      config.feedbackModule = true;
      break;
    case 'llm_chat':
    case 'widget':
    case 'ci':
      // controlled by features.dart / patch — no extra kit_setup flag needed
      break;
    default:
      break;
  }
}

// ── Module definitions ────────────────────────────────────────────────────────

const MODULE_META = {
  sentry: {
    promptKeys: ['sentryDsn'],
    defineUpdates: (a) => ({ SENTRY_DSN: a.sentryDsn || 'YOUR_SENTRY_DSN' }),
    envLines: (a) => [`SENTRY_DSN=${a.sentryDsn || 'YOUR_SENTRY_DSN'}`],
    featureFlag: null,
    pubspecDeps: { 'sentry_flutter': '^9.14.0' },
  },
  analytics: {
    promptKeys: ['mixpanelToken'],
    defineUpdates: (a) => ({ MIXPANEL_TOKEN: a.mixpanelToken || 'YOUR_MIXPANEL_TOKEN' }),
    envLines: (a) => [`MIXPANEL_TOKEN=${a.mixpanelToken || 'YOUR_MIXPANEL_TOKEN'}`],
    featureFlag: null,
    pubspecDeps: { 'mixpanel_flutter': '^2.5.0' },
  },
  facebook: {
    promptKeys: [],
    defineUpdates: () => ({}),
    envLines: () => [],
    featureFlag: null,
    note: 'add.note.facebook',
    pubspecDeps: { 'facebook_app_events': '^0.24.0', 'flutter_facebook_auth': '^7.1.5' },
  },
  revenuecat: {
    promptKeys: ['rcTestKey', 'rcIosProdKey', 'rcAndroidProdKey'],
    defineUpdates: (a) => {
      const { android, ios } = resolveDefaultRcKeys(a);
      return {
        RC_ANDROID_API_KEY: android,
        RC_IOS_API_KEY: ios,
      };
    },
    envLines: (a) => [
      `RC_TEST_KEY=${(a.rcTestKey || '').trim()}`,
      `RC_IOS_PROD_KEY=${(a.rcIosProdKey || '').trim()}`,
      `RC_ANDROID_PROD_KEY=${(a.rcAndroidProdKey || '').trim()}`,
    ],
    featureFlag: null,
    pubspecDeps: {},
  },
  onboarding: {
    promptKeys: [],
    defineUpdates: () => ({}),
    envLines: () => [],
    featureFlag: 'withOnboarding',
    pubspecDeps: {},
  },
  web: {
    promptKeys: [],
    defineUpdates: () => ({}),
    envLines: () => [],
    featureFlag: 'withWeb',
    pubspecDeps: {},
  },
  feedback: {
    promptKeys: [],
    defineUpdates: () => ({}),
    envLines: () => [],
    featureFlag: 'withFeedback',
    pubspecDeps: {},
  },
  llm_chat: {
    promptKeys: ['llmProvider', 'llmSystemPrompt', 'llmApiKey'],
    defineUpdates: (a, kitSetup) => ({
      LLM_CHAT_ENDPOINT: resolveLlmChatEndpoint(a, kitSetup),
    }),
    envLines: (a, kitSetup) => [
      `LLM_CHAT_ENDPOINT=${resolveLlmChatEndpoint(a, kitSetup)}`,
    ],
    featureFlag: 'withLlmChat',
    pubspecDeps: {},
  },
  widget: {
    promptKeys: [],
    defineUpdates: () => ({}),
    envLines: () => [],
    featureFlag: null,
    pubspecDeps: { 'home_widget': '^0.9.0', 'background_fetch': '^1.5.0' },
  },
  ci: {
    promptKeys: [],
    defineUpdates: () => ({}),
    envLines: () => [],
    featureFlag: null,
    pubspecDeps: {},
  },
};

/**
 * Each entry returns a fn (t, cancel) -> Promise<value> that asks the user.
 * Secret-like values (DSNs, API keys, tokens) use ui.password to mask input.
 */
const PROMPT_QUESTIONS = {
  sentryDsn: (t, cancel) => ui.password({
    message: t('add.prompt.sentryDsn'),
    onCancel: cancel,
  }),
  mixpanelToken: (t, cancel) => ui.password({
    message: t('add.prompt.mixpanelToken'),
    onCancel: cancel,
  }),
  // RevenueCat SDK keys ship inside the client binary — not real secrets, plain text input.
  // Three optional keys: test_ (covers iOS+Android in simulator), appl_ (iOS prod), goog_ (Android prod).
  // kasy run picks the right one based on the device at launch time.
  rcTestKey: (t, cancel) => ui.text({
    message: t('add.prompt.rcTestKey'),
    validate: (v) => {
      const s = (v || '').trim();
      if (!s) return undefined;
      return /^test_/.test(s) ? undefined : t('new.firebase.q.revenuecat.test.invalid');
    },
    onCancel: cancel,
  }),
  rcIosProdKey: (t, cancel) => ui.text({
    message: t('add.prompt.rcIosProdKey'),
    validate: (v) => {
      const s = (v || '').trim();
      if (!s) return undefined;
      return /^appl_/.test(s) ? undefined : t('new.firebase.q.revenuecat.iosProd.invalid');
    },
    onCancel: cancel,
  }),
  rcAndroidProdKey: (t, cancel) => ui.text({
    message: t('add.prompt.rcAndroidProdKey'),
    validate: (v) => {
      const s = (v || '').trim();
      if (!s) return undefined;
      return /^goog_/.test(s) ? undefined : t('new.firebase.q.revenuecat.androidProd.invalid');
    },
    onCancel: cancel,
  }),
  llmProvider: (t, cancel) => ui.select({
    message: t('add.prompt.llmProvider'),
    initialValue: 'openai',
    options: [
      { value: 'openai', label: 'OpenAI (gpt-4o-mini)' },
      { value: 'gemini', label: 'Google Gemini (gemini-1.5-flash)' },
    ],
    onCancel: cancel,
  }),
  llmSystemPrompt: (t, cancel) => ui.text({
    message: t('add.prompt.llmSystemPrompt'),
    onCancel: cancel,
  }),
  llmApiKey: (t, cancel) => ui.password({
    message: t('add.prompt.llmApiKey'),
    onCancel: cancel,
  }),
  llmEndpoint: (t, cancel) => ui.text({
    message: t('add.prompt.llmEndpoint'),
    onCancel: cancel,
  }),
};

// ── LLM Chat post-add: copy edge function, set secrets, write functions/.env ──

const SUPABASE_EDGE_FUNCTIONS_SOURCE = path.join(
  __dirname, '..', 'scaffold', 'backends', 'supabase', 'edge-functions'
);

async function postAddLlmChat(projectDir, kitSetup, answers, t) {
  const backend = kitSetup?.backendProvider ?? 'firebase';
  const apiKey = answers.llmApiKey?.trim() || '';
  const provider = answers.llmProvider || 'openai';
  const systemPrompt = answers.llmSystemPrompt?.trim() || '';
  const projectRef = kitSetup?.supabaseProjectId || '';

  // 1. Firebase: write functions/.env (non-secret vars read by Cloud Functions runtime)
  if (backend === 'firebase') {
    const envPath = path.join(projectDir, 'functions', '.env');
    const lines = [`LLM_PROVIDER=${provider}`];
    if (systemPrompt) lines.push(`LLM_SYSTEM_PROMPT=${systemPrompt}`);
    const existing = (await fs.pathExists(envPath)) ? await fs.readFile(envPath, 'utf8') : '';
    const toAdd = lines.filter((l) => !existing.includes(l.split('=')[0]));
    if (toAdd.length > 0) {
      await fs.appendFile(envPath, (existing ? '\n' : '') + toAdd.join('\n') + '\n', 'utf8');
    }
  }

  // 1b. Supabase: copy Edge Function file + set ALL vars as Supabase Secrets
  //    (Edge Functions deployed on Supabase read from Deno.env.get() = Supabase Secrets,
  //     NOT from local .env files — so LLM_PROVIDER and LLM_SYSTEM_PROMPT must be secrets too)
  if (backend === 'supabase') {
    // Copy llm-chat edge function into the project if not already present
    const edgeFnSrc = path.join(SUPABASE_EDGE_FUNCTIONS_SOURCE, 'llm-chat');
    const edgeFnDest = path.join(projectDir, 'supabase', 'functions', 'llm-chat');
    if (await fs.pathExists(edgeFnSrc) && !(await fs.pathExists(edgeFnDest))) {
      await fs.copy(edgeFnSrc, edgeFnDest);
    }
  }

  // 2. Set API key as secret (skip if blank)
  if (!apiKey) {
    ui.log.warn(t('add.llm_chat.skipSecret'));
    return { deployOk: false, deployAttempted: false };
  }

  let secretsOk = false;
  const refFlag = backend === 'supabase' && projectRef ? ` --project-ref ${projectRef}` : '';

  if (backend === 'firebase') {
    const spinner = ui.spinner();
    spinner.start(t('add.llm_chat.settingSecret'));
    try {
      // Write to temp file — avoids trailing newline (echo) and shell injection risks
      const tmpFile = path.join(os.tmpdir(), `llm_api_key_${Date.now()}.tmp`);
      await fs.outputFile(tmpFile, apiKey, 'utf8');
      await execAsync(`firebase functions:secrets:set LLM_API_KEY --data-file="${tmpFile}"`, { cwd: projectDir });
      await fs.remove(tmpFile);
      spinner.stop(t('add.llm_chat.secretSet'));
      secretsOk = true;
    } catch {
      spinner.stop(`⚠ ${t('add.llm_chat.secretFailed')}`);
      ui.log.message(kleur.dim('Run manually: firebase functions:secrets:set LLM_API_KEY'));
    }
  } else if (backend === 'supabase') {
    const spinner = ui.spinner();
    spinner.start(t('add.llm_chat.settingSecret'));
    // Set LLM_API_KEY, LLM_PROVIDER and LLM_SYSTEM_PROMPT all as Supabase Secrets.
    // Deployed Edge Functions read from Deno.env.get() = Supabase Secrets, NOT from .env files.
    const esc = (v) => String(v).replace(/"/g, '\\"');
    const toSet = [
      { name: 'LLM_API_KEY', value: apiKey },
      { name: 'LLM_PROVIDER', value: provider },
      ...(systemPrompt ? [{ name: 'LLM_SYSTEM_PROMPT', value: systemPrompt }] : []),
    ];
    let allOk = true;
    for (const { name, value } of toSet) {
      const r = await execAsync(
        `supabase secrets set ${name}="${esc(value)}"${refFlag}`,
        { cwd: projectDir }
      ).catch((e) => ({ ok: false, error: e.message }));
      if (r && r.ok === false) allOk = false;
    }
    if (allOk) {
      spinner.stop(t('add.llm_chat.secretSet'));
      secretsOk = true;
    } else {
      spinner.stop(`⚠ ${t('add.llm_chat.secretFailed')}`);
      ui.log.message(kleur.dim(`Run manually: supabase secrets set LLM_API_KEY=YOUR_KEY LLM_PROVIDER=${provider}${refFlag}`));
    }
  }

  // 3. Deploy the LLM function automatically
  if (backend === 'api') return { deployOk: false, deployAttempted: false };

  const deploySpinner = ui.timedSpinner();
  deploySpinner.start(t('add.llm_chat.deploying'));
  try {
    if (backend === 'firebase') {
      await execAsync('firebase deploy --only functions:llmChat', { cwd: projectDir, timeout: 180_000 });
    } else if (backend === 'supabase') {
      await execAsync(`supabase functions deploy llm-chat --no-verify-jwt${refFlag}`, { cwd: projectDir, timeout: 180_000 });
    }
    deploySpinner.stop(t('add.llm_chat.deployed'));
    return { deployOk: true, deployAttempted: true };
  } catch {
    deploySpinner.stop(`⚠ ${t('add.llm_chat.deployFailed')}`);
    if (backend === 'firebase') {
      ui.log.message(kleur.dim('Run manually: firebase deploy --only functions:llmChat'));
    } else {
      ui.log.message(kleur.dim(`Run manually: supabase functions deploy llm-chat --no-verify-jwt${refFlag}`));
    }
    return { deployOk: false, deployAttempted: true };
  }
}

// ── List command ──────────────────────────────────────────────────────────────

async function listModules(projectDir, t) {
  let activeModules = [];
  const kitSetupPath = path.join(projectDir, 'kit_setup.json');
  if (await fs.pathExists(kitSetupPath)) {
    try {
      const config = await fs.readJson(kitSetupPath);
      activeModules = await getActiveModules(config, projectDir);
    } catch {
      // ignore
    }
  }

  const lines = [];

  lines.push(kleur.bold(t('modules.featuresBase')));
  for (const feature of getBaseFeatures()) {
    lines.push(`${kleur.green('✓')}  ${kleur.green(feature.displayName)}`);
  }

  // Subscriptions is base, but inactive until RevenueCat is added.
  if (!activeModules.includes('revenuecat')) {
    lines.push('');
    lines.push(kleur.dim(t('modules.hint.subscriptionNoRc')));
  }

  lines.push('');
  lines.push(kleur.bold(t('modules.features')));
  for (const feature of getVisibleFeatures({ audience: KASY_AUDIENCE })) {
    const active = activeModules.includes(feature.id);
    const betaBadge = feature.status === 'internal' ? kleur.yellow(' [beta]') : '';
    const tagBadge = feature.tag ? kleur.yellow(` [${t(`modules.tag.${feature.tag}`)}]`) : '';
    const enhancesBadge = feature.enhances
      ? kleur.magenta(` [${t('modules.tag.enhances', { target: findBaseDisplayName(feature.enhances) })}]`)
      : '';
    const icon = active ? kleur.green('✓') : kleur.dim('○');
    const label = active ? kleur.green(feature.displayName) : kleur.white(feature.displayName);
    lines.push(`${icon}  ${label}${tagBadge}${enhancesBadge}${betaBadge}`);
  }
  ui.note(lines.join('\n'), t('add.list.title'));
}

// ── Main command ──────────────────────────────────────────────────────────────

async function runAdd(module, options = {}) {
  const t = createTranslator(options.language || detectDefaultLanguage());
  const projectDir = path.resolve(options.directory || '.');
  const cancel = () => { ui.cancel(t('add.cancelled')); process.exit(0); };

  // --list flag
  if (options.list) {
    printCompactHeader(t);
    ui.intro(t('add.list.title'));
    await listModules(projectDir, t);
    ui.outro('');
    return;
  }

  if (!module) {
    printCompactHeader(t);
    ui.intro(t('add.list.title'));
    ui.log.warn(t('add.error.noModule'));
    await listModules(projectDir, t);
    ui.outro('');
    return;
  }

  // 1. Must be inside a kasy project
  const kitSetupPath = path.join(projectDir, 'kit_setup.json');
  if (!(await fs.pathExists(kitSetupPath))) {
    throw new Error(t('add.error.notKasyProject'));
  }

  const kitSetup = await fs.readJson(kitSetupPath);
  try {
    const kasyConfig = await fs.readJson(path.join(projectDir, '.kasy', 'config.json'));
    if (kasyConfig.functionsRegion) kitSetup.functionsRegion = kasyConfig.functionsRegion;
  } catch (_) {}

  const moduleLower = String(module).trim().toLowerCase();
  if (moduleLower === 'ios-release' || moduleLower === 'ios_release' || moduleLower === 'iosrelease') {
    const scriptPath = path.join(projectDir, 'scripts', 'release-ios.sh');
    if (await fs.pathExists(scriptPath)) {
      printCompactHeader(t);
      ui.log.warn(t('add.iosRelease.already'));
      return;
    }
    const patchDir = path.join(FEATURES_PATCH_DIR, 'ios-release');
    if (!(await fs.pathExists(patchDir))) {
      throw new Error(t('add.error.unknownModule', { module, list: 'ios-release' }));
    }
    printCompactHeader(t);
    ui.intro(t('add.applying', { module: 'ios-release' }));
    const { tokens, pathReplacements } = buildTokens({
      appName: kitSetup.appName,
      bundleId: kitSetup.bundleId,
    });
    await applyPatch(patchDir, projectDir, tokens, pathReplacements);
    await localizeReleaseDocs(projectDir, options.language || detectDefaultLanguage());
    await ensureIosReleaseMakefile(projectDir);
    await ensureGitignoreKasyEnv(projectDir);
    ui.log.success(t('add.iosRelease.success'));
    ui.outro(kleur.cyan('kasy ios configure'));
    return;
  }

  // 2. Validate module name
  const normalized = normalizeFeature(module);
  if (!normalized) {
    throw new Error(
      t('add.error.unknownModule', {
        module,
        list: getVisibleFeatures({ audience: KASY_AUDIENCE }).map((f) => f.id).join(', '),
      })
    );
  }

  // 3. Check if already active
  const activeModules = await getActiveModules(kitSetup, projectDir);
  if (activeModules.includes(normalized)) {
    // llm_chat can be re-run to reconfigure credentials even when already active
    if (normalized !== 'llm_chat') {
      printCompactHeader(t);
      ui.log.warn(t('add.alreadyActive', { module: normalized }));
      return;
    }
    printCompactHeader(t);
    ui.intro(t('add.llm_chat.reconfigure'));
    // Skip to credential-only flow: prompt → postAdd → done
    const answers = {};
    if (!options.yes) {
      for (const key of ['llmProvider', 'llmSystemPrompt', 'llmApiKey']) {
        const ask = PROMPT_QUESTIONS[key];
        if (!ask) continue;
        answers[key] = (await ask(t, cancel)) ?? '';
      }
      if (kitSetup.backendProvider === 'api') {
        answers.llmEndpoint = (await PROMPT_QUESTIONS.llmEndpoint(t, cancel)) || '';
      }
    }
    const defines = MODULE_META.llm_chat.defineUpdates(answers, kitSetup);
    if (Object.keys(defines).length > 0) {
      await updateLaunchJson(projectDir, defines);
      await updateMakefile(projectDir, defines);
      const envLines = MODULE_META.llm_chat.envLines(answers, kitSetup);
      if (envLines.length > 0) {
        await appendEnvExample(projectDir, envLines);
        await appendEnvFile(projectDir, envLines);
      }
    }
    const { deployOk, deployAttempted } = await postAddLlmChat(projectDir, kitSetup, answers, t);
    const backend = kitSetup?.backendProvider ?? 'firebase';
    const needsManualDeploy = deployAttempted && !deployOk;
    let nextStepsMsg;
    if (backend === 'firebase') {
      nextStepsMsg = t(needsManualDeploy ? 'add.llm_chat.nextSteps.firebase.deployFailed' : 'add.llm_chat.nextSteps.firebase');
    } else if (backend === 'supabase') {
      nextStepsMsg = t(needsManualDeploy ? 'add.llm_chat.nextSteps.supabase.deployFailed' : 'add.llm_chat.nextSteps.supabase');
    } else {
      nextStepsMsg = t('add.llm_chat.nextSteps.api');
    }
    ui.outro(kleur.cyan(nextStepsMsg));
    return;
  }

  const meta = MODULE_META[normalized] || { promptKeys: [], defineUpdates: () => ({}), envLines: () => [], featureFlag: null };

  printCompactHeader(t);
  ui.intro(t('add.applying', { module: normalized }));

  // 4. Prompt for API keys (skip if --yes)
  const answers = {};
  if (!options.yes && meta.promptKeys.length > 0) {
    for (const key of meta.promptKeys) {
      const ask = PROMPT_QUESTIONS[key];
      if (!ask) continue;
      answers[key] = (await ask(t, cancel)) ?? '';
    }
    // Extra prompt for API backend (custom LLM endpoint)
    if (normalized === 'llm_chat' && kitSetup.backendProvider === 'api') {
      answers.llmEndpoint = (await PROMPT_QUESTIONS.llmEndpoint(t, cancel)) || '';
    }
  }

  // 5. Enable feature flag in features.dart
  if (meta.featureFlag) {
    await enableFeatureFlag(projectDir, meta.featureFlag);
  }

  // 6. Update .vscode/launch.json and Makefile dart-defines
  const defines = meta.defineUpdates(answers, kitSetup);
  if (Object.keys(defines).length > 0) {
    await updateLaunchJson(projectDir, defines);
    await updateMakefile(projectDir, defines);
  }

  // 7. Append to .env.example
  const envLines = meta.envLines(answers, kitSetup);
  if (envLines.length > 0) {
    await appendEnvExample(projectDir, envLines);
    await appendEnvFile(projectDir, envLines);
  }

  // 8. Apply patch if it exists under features/<module>/
  const patchDir = path.join(FEATURES_PATCH_DIR, normalized);
  if (await fs.pathExists(patchDir)) {
    const spinner = ui.spinner();
    spinner.start(t('add.applyingPatch'));
    try {
      const { tokens: patchTokens, pathReplacements: patchPathReplacements } = buildTokens({
        appName: kitSetup.appName,
        bundleId: kitSetup.bundleId,
      });
      await applyPatch(patchDir, projectDir, patchTokens, patchPathReplacements);
      spinner.stop(t('add.patchApplied'));
    } catch (err) {
      spinner.error(t('add.patchFailed'));
      throw err;
    }
  }

  // 8b. Add pubspec dependencies for this module
  const pubspecDeps = meta.pubspecDeps || {};
  if (Object.keys(pubspecDeps).length > 0) {
    for (const [depName, depVersion] of Object.entries(pubspecDeps)) {
      await addPubspecDep(projectDir, depName, depVersion);
    }
  }

  // 9. Update kit_setup.json
  applyKitSetupFlag(kitSetup, normalized, answers);
  kitSetup.cliVersion = pkg.version;
  await fs.outputFile(kitSetupPath, JSON.stringify(kitSetup, null, 2) + '\n', 'utf8');

  // 9b. Regenerate router.dart with updated module list
  try {
    const updatedModules = await getActiveModules(kitSetup, projectDir);
    const packageName = toPackageName(kitSetup.appName);
    await writeRouter(projectDir, updatedModules, packageName);
  } catch {
    // Non-fatal: router regeneration failure does not block pub get
  }

  // 9c. Regenerate main.dart and no-op stubs with updated module list
  try {
    const updatedModules = await getActiveModules(kitSetup, projectDir);
    const packageName = toPackageName(kitSetup.appName);
    if (!updatedModules.includes('analytics')) {
      await writeNoOpAnalyticsApi(projectDir, packageName);
    }
    if (!updatedModules.includes('facebook')) {
      await writeNoOpTrackingApi(projectDir, packageName);
    }
    if (!updatedModules.includes('feedback')) {
      await writeNoOpFeatureRequestRepository(projectDir, packageName);
    }
    if (!updatedModules.includes('widget')) {
      await writeNoOpAdminHomeWidgets(projectDir);
    }
    await writeMainDart(projectDir, kitSetup.backendProvider, updatedModules, packageName, {});
  } catch {
    // Non-fatal: main.dart regeneration failure does not block pub get
  }

  // 10. flutter pub get
  {
    const spinner = ui.spinner();
    spinner.start(t('add.pubGet'));
    try {
      await execAsync('flutter pub get', { cwd: projectDir, timeout: 300_000 });
      spinner.stop(t('add.pubGetDone'));
    } catch {
      spinner.stop(`⚠ ${t('add.pubGetFailed')}`);
    }
  }

  // 11. build_runner (only when needed: features with codegen)
  const needsBuildRunner = ['revenuecat', 'analytics', 'sentry', 'onboarding', 'llm_chat', 'feedback'].includes(normalized);
  if (needsBuildRunner) {
    const spinner = ui.timedSpinner();
    spinner.start(t('add.buildRunner'));
    try {
      await execAsync('dart run build_runner build --delete-conflicting-outputs', { cwd: projectDir, timeout: 600_000 });
      spinner.stop(t('add.buildRunnerDone'));
    } catch {
      spinner.stop(`⚠ ${t('add.buildRunnerFailed')}`);
    }
  }

  // 12. LLM Chat: set secrets, write env vars, and deploy
  let llmDeployResult = null;
  if (normalized === 'llm_chat') {
    llmDeployResult = await postAddLlmChat(projectDir, kitSetup, answers, t);
  }

  // Show note if any
  if (meta.note) {
    ui.log.message(kleur.dim(t(meta.note)));
  }

  // LLM Chat: show next steps (adjust based on whether deploy succeeded)
  if (normalized === 'llm_chat') {
    const backend = kitSetup?.backendProvider ?? 'firebase';
    const needsManualDeploy = llmDeployResult?.deployAttempted && !llmDeployResult?.deployOk;
    let nextStepsMsg;
    if (backend === 'firebase') {
      nextStepsMsg = t(needsManualDeploy ? 'add.llm_chat.nextSteps.firebase.deployFailed' : 'add.llm_chat.nextSteps.firebase');
    } else if (backend === 'supabase') {
      nextStepsMsg = t(needsManualDeploy ? 'add.llm_chat.nextSteps.supabase.deployFailed' : 'add.llm_chat.nextSteps.supabase');
    } else {
      nextStepsMsg = t('add.llm_chat.nextSteps.api');
    }
    ui.log.info(nextStepsMsg);
  }

  ui.outro(t('add.success', { module: normalized }));
}

module.exports = { runAdd };
