/**
 * `getmcp add` command.
 *
 * Workflow:
 * 1. User picks a server from the registry (or provides ID as argument)
 * 2. If server has required env vars, prompt for them
 * 3. Detect installed AI apps
 * 4. User selects which apps to configure
 * 5. Generate config for each selected app
 * 6. Merge into existing config files (never overwrite)
 *
 * Supports non-interactive mode via --yes, --app, --all-apps flags.
 * Supports --dry-run to preview changes without writing.
 */

import * as p from "@clack/prompts";
import { getAllServers, getServer, searchServers } from "@getmcp/registry";
import { getGenerator, getAppIds } from "@getmcp/generators";
import { isStdioConfig } from "@getmcp/core";
import type { LooseServerConfigType, AppIdType } from "@getmcp/core";
import type { InternalRegistryEntry } from "@getmcp/registry";
import { generators } from "@getmcp/generators";
import { detectApps, resolveAppForScope, type DetectedApp } from "../detect.js";
import { resolveAppsFromFlags, resolveScope } from "../app-selection.js";
import { mergeServerIntoConfig, writeConfigFile } from "../config-file.js";
import { getSavedSelectedApps, saveSelectedApps } from "../preferences.js";
import { trackInstallation } from "../lock.js";
import { shortenPath, isNonInteractive as isNonInteractiveCheck } from "../utils.js";
import {
  ServerNotFoundError,
  AppNotDetectedError,
  InvalidAppError,
  NonInteractiveError,
  formatError,
} from "../errors.js";

export interface AddOptions {
  yes?: boolean;
  apps?: string[];
  allApps?: boolean;
  dryRun?: boolean;
  json?: boolean;
  fromNpm?: string;
  fromPypi?: string;
  fromUrl?: string;
  global?: boolean;
  project?: boolean;
  registry?: string;
}

export async function addCommand(serverIdArg?: string, options: AddOptions = {}): Promise<void> {
  const isNonInteractive = isNonInteractiveCheck(options);

  p.intro("getmcp add");

  // Handle --from-npm / --from-pypi / --from-url (unverified packages)
  if (options.fromNpm || options.fromPypi || options.fromUrl) {
    return addUnverifiedServer(options);
  }

  // Step 1: Select server
  let entry: InternalRegistryEntry;

  if (serverIdArg) {
    const found = getServer(serverIdArg);
    if (!found) {
      // Try fuzzy search
      const matches = searchServers(serverIdArg);
      if (matches.length === 0) {
        p.log.error(new ServerNotFoundError(serverIdArg).format());
        process.exit(1);
      }
      if (matches.length === 1) {
        entry = matches[0];
      } else if (isNonInteractive) {
        // In non-interactive mode, take first match
        entry = matches[0];
        p.log.info(`Multiple matches for "${serverIdArg}", using: ${matches[0].name}`);
      } else {
        const selected = await p.select({
          message: `Multiple matches for "${serverIdArg}". Pick one:`,
          options: matches.map((s) => ({
            label: s.name,
            hint: s.description,
            value: s,
          })),
        });

        if (p.isCancel(selected)) {
          p.cancel("Operation cancelled.");
          process.exit(0);
        }
        entry = selected;
      }
    } else {
      entry = found;
    }
  } else if (isNonInteractive) {
    p.log.error(new NonInteractiveError("server ID is required in non-interactive mode").format());
    process.exit(1);
  } else {
    const servers = getAllServers();
    const selected = await p.select({
      message: "Select an MCP server to install:",
      options: servers.map((s) => ({
        label: s.name,
        hint: s.description,
        value: s,
      })),
    });

    if (p.isCancel(selected)) {
      p.cancel("Operation cancelled.");
      process.exit(0);
    }
    entry = selected;
  }

  p.log.info(`Installing: ${entry.name}\n  ${entry.description}`);

  // Step 2: Prompt for required environment variables
  const config = structuredClone(entry.config) as LooseServerConfigType;

  if (entry.requiredEnvVars.length > 0 && isStdioConfig(config)) {
    // Warn about dangerous env var names from registry entries
    const dangerousVars = entry.requiredEnvVars.filter((v) => DANGEROUS_ENV_VARS.has(v));
    if (dangerousVars.length > 0) {
      p.log.warn(
        `This server requests potentially dangerous environment variables:\n` +
          dangerousVars.map((v) => `  ${v}`).join("\n") +
          `\n  These variables can affect system behavior. Proceed with caution.`,
      );
    }

    config.env ??= {};
    if (isNonInteractive) {
      // In non-interactive mode, check if env vars are already set in the environment
      let allSet = true;
      for (const envVar of entry.requiredEnvVars) {
        const envValue = process.env[envVar];
        if (envValue) {
          config.env[envVar] = envValue;
        } else {
          allSet = false;
          p.log.warn(`Required env var ${envVar} is not set in environment`);
        }
      }
      if (!allSet) {
        p.log.warn(
          "Some required environment variables are missing. The server may not work correctly.\n  Set them in your shell environment and re-run, or run interactively to be prompted.",
        );
      }
    } else {
      p.log.step("This server requires environment variables:");
      for (const envVar of entry.requiredEnvVars) {
        const isSensitive = SENSITIVE_PATTERNS.test(envVar);
        const promptFn = isSensitive ? p.password : p.text;
        const value = await promptFn({
          message: `${envVar}:`,
          validate: (val) => {
            if (!val || !val.trim()) return `${envVar} is required`;
          },
        });

        if (p.isCancel(value)) {
          p.cancel("Operation cancelled.");
          process.exit(0);
        }
        config.env[envVar] = (value as string).trim();
      }
    }
  }

  // Step 3: Detect apps and build selection list
  const allApps = detectApps();
  const detected = allApps.filter((app) => app.exists);
  const notDetectedProjectScoped = allApps.filter(
    (app) => !app.exists && generators[app.id].app.configPaths !== null,
  );

  // Step 4: Select target apps
  let selectedApps: DetectedApp[];

  if (options.allApps) {
    // --all-apps: use all detected apps
    selectedApps = detected;
    if (selectedApps.length === 0) {
      p.log.error(new AppNotDetectedError().format());
      printManualConfig(entry, config);
      p.outro("Done");
      return;
    }
  } else if (options.apps && options.apps.length > 0) {
    // --app <id>: use specified apps
    const validIds = getAppIds();
    selectedApps = [];

    for (const appId of options.apps) {
      if (!validIds.includes(appId as AppIdType)) {
        p.log.error(new InvalidAppError(appId, validIds).format());
        process.exit(1);
      }

      const app = allApps.find((a) => a.id === appId);
      if (app) {
        selectedApps.push(app);
      } else {
        p.log.warn(`App "${appId}" has no config path for this platform. Skipping.`);
      }
    }

    if (selectedApps.length === 0) {
      p.log.error("None of the specified apps are available on this platform.");
      process.exit(1);
    }
  } else if (isNonInteractive) {
    // Non-interactive without --app: use all detected
    selectedApps = detected;
    if (selectedApps.length === 0) {
      p.log.error(new AppNotDetectedError().format());
      printManualConfig(entry, config);
      p.outro("Done");
      return;
    }
  } else {
    // Interactive mode
    if (detected.length === 0 && notDetectedProjectScoped.length === 0) {
      p.log.warn(
        "No AI applications detected on this system.\n  You can manually copy the config from below:",
      );
      printManualConfig(entry, config);
      p.outro("Done");
      return;
    }

    const savedApps = getSavedSelectedApps();
    const hasSavedPreferences = savedApps !== null;

    const choices = [
      ...detected.map((app) => ({
        label: app.name,
        value: app,
        hint: shortenPath(app.configPath),
      })),
      ...notDetectedProjectScoped.map((app) => ({
        label: app.name,
        value: app,
        hint: `${shortenPath(app.configPath)} (not detected)`,
      })),
    ];

    const initialValues = choices
      .filter((c) => (hasSavedPreferences ? savedApps.includes(c.value.id) : c.value.exists))
      .map((c) => c.value);

    const initialIds = new Set(initialValues.map((v) => v.id));
    choices.sort((a, b) => {
      const aSelected = initialIds.has(a.value.id) ? 0 : 1;
      const bSelected = initialIds.has(b.value.id) ? 0 : 1;
      return aSelected - bSelected;
    });

    const selected = await p.multiselect({
      message: "Select apps to configure:",
      options: choices,
      initialValues,
      required: true,
    });

    if (p.isCancel(selected)) {
      p.cancel("Operation cancelled.");
      process.exit(0);
    }

    selectedApps = selected;

    // Save selected apps for next run
    saveSelectedApps(selectedApps.map((app) => app.id));
  }

  // Step 4.5: Scope selection for dual-scope apps
  if (options.global && options.project) {
    p.log.error("Cannot use both --global and --project. Choose one.");
    process.exit(1);
  }

  let chosenScope: "project" | "global" = "project";
  const dualScopeApps = selectedApps.filter((a) => a.supportsBothScopes);
  if (dualScopeApps.length > 0) {
    if (options.global) {
      chosenScope = "global";
    } else if (options.project) {
      chosenScope = "project";
    } else if (isNonInteractive) {
      chosenScope = "project";
    } else {
      const scopeChoice = await p.select({
        message: "Install globally or per-project?",
        options: [
          { label: "Project", hint: "config in current directory", value: "project" as const },
          { label: "Global", hint: "config in home directory", value: "global" as const },
        ],
        initialValue: "project" as const,
      });
      if (p.isCancel(scopeChoice)) {
        p.cancel("Operation cancelled.");
        process.exit(0);
      }
      chosenScope = scopeChoice;
    }
    selectedApps = selectedApps.map((app) => resolveAppForScope(app, chosenScope));
  }

  // Step 5+6: Generate and merge for each selected app
  if (options.dryRun) {
    p.log.step("Dry run — showing what would be written:\n");
  }

  const spin = p.spinner();
  spin.start(`Configuring ${selectedApps.length} app(s)...`);

  const results: { app: DetectedApp; ok: boolean; error?: string }[] = [];

  for (const app of selectedApps) {
    try {
      const generator = getGenerator(app.id);
      const generatedConfig = generator.generate(entry.id, config);

      if (options.dryRun) {
        spin.stop(`Preview for ${app.name}:`);
        const serialized = generator.serialize(generatedConfig);
        p.note(`File: ${shortenPath(app.configPath)}\n\n${serialized}`, app.name);
        spin.start("Continuing...");
        results.push({ app, ok: true });
      } else {
        const merged = mergeServerIntoConfig(app.configPath, generatedConfig);
        writeConfigFile(app.configPath, merged);
        results.push({ app, ok: true });
      }
    } catch (err) {
      results.push({
        app,
        ok: false,
        error: formatError(err),
      });
    }
  }

  spin.stop("Configuration complete.");

  // Track installation (unless dry-run)
  if (!options.dryRun) {
    const successResults = results.filter((r) => r.ok);
    const successAppIds = successResults.map((r) => r.app.id);
    if (successAppIds.length > 0) {
      const scopes: Record<string, "project" | "global"> = {};
      for (const r of successResults) {
        scopes[r.app.id] = r.app.supportsBothScopes ? chosenScope : "project";
      }
      const registrySource = options.registry ?? entry.registrySource;
      trackInstallation(
        entry.id,
        successAppIds,
        entry.requiredEnvVars,
        undefined,
        scopes,
        registrySource,
      );
    }
  }

  // JSON output mode
  if (options.json) {
    const output = {
      server: entry.id,
      name: entry.name,
      dryRun: options.dryRun ?? false,
      apps: results.map((r) => ({
        id: r.app.id,
        name: r.app.name,
        configPath: r.app.configPath,
        ok: r.ok,
        ...(r.error ? { error: r.error } : {}),
      })),
    };
    console.log(JSON.stringify(output, null, 2));
    return;
  }

  // Show results summary
  for (const r of results) {
    if (r.ok) {
      p.log.success(`${r.app.name}: ${shortenPath(r.app.configPath)}`);
    } else {
      p.log.error(`${r.app.name}: ${r.error}`);
    }
  }

  const action = options.dryRun ? "would be configured" : "has been configured";
  p.outro(`"${entry.name}" ${action}.`);

  // Warn about .gitignore for project-scoped configs with env vars
  if (!options.dryRun && entry.requiredEnvVars.length > 0 && chosenScope === "project") {
    const projectPaths = results.filter((r) => r.ok).map((r) => shortenPath(r.app.configPath));
    if (projectPaths.length > 0) {
      p.log.warn(
        "Env var values were written to project-scoped config files.\n" +
          "  Make sure these paths are in your .gitignore to avoid committing secrets:\n" +
          projectPaths.map((p) => `    ${p}`).join("\n"),
      );
    }
  }

  // Reminder for apps that need restart
  const needsRestart = selectedApps.some((a) => generators[a.id].app.requiresRestart === true);
  if (needsRestart && !options.dryRun) {
    p.log.info("Some apps may need to be restarted to pick up changes.");
  }

  // PyCharm-specific warning
  const hasPycharm = selectedApps.some((a) => a.id === "pycharm");
  if (hasPycharm && !options.dryRun) {
    p.log.warn(
      "MCP servers in PyCharm require the JetBrains AI Assistant plugin:\n" +
        "  https://plugins.jetbrains.com/plugin/22282-jetbrains-ai-assistant\n\n" +
        "  PyCharm must be closed and reopened for changes to take effect.",
    );
  }
}

async function addUnverifiedServer(options: AddOptions): Promise<void> {
  const isNonInteractive = isNonInteractiveCheck(options);

  let serverName: string;
  let config: LooseServerConfigType;

  if (options.fromUrl) {
    // Remote server from URL
    const url = options.fromUrl;
    let parsed: URL;
    try {
      parsed = new URL(url);
    } catch {
      p.log.error("Invalid URL. Provide a valid http or https URL.");
      process.exit(1);
    }
    if (!["http:", "https:"].includes(parsed.protocol)) {
      p.log.error("URL must use http or https protocol.");
      process.exit(1);
    }
    serverName = parsed.hostname.replace(/\./g, "-");
    config = { url } as LooseServerConfigType;
    p.log.warn("Unverified remote server — not from the getmcp registry.");
  } else if (options.fromNpm) {
    // npm package
    const pkg = options.fromNpm;
    serverName = pkg.replace(/^@/, "").replace(/\//g, "-");
    config = {
      command: "npx",
      args: ["-y", pkg],
    } as LooseServerConfigType;
    p.log.warn(`Unverified npm package: ${pkg} — not from the getmcp registry.`);
  } else if (options.fromPypi) {
    // PyPI package
    const pkg = options.fromPypi;
    serverName = pkg.replace(/\//g, "-");
    config = {
      command: "uvx",
      args: [pkg],
    } as LooseServerConfigType;
    p.log.warn(`Unverified PyPI package: ${pkg} — not from the getmcp registry.`);
  } else {
    return;
  }

  // Validate the derived server name
  if (UNSAFE_KEYS.has(serverName) || !VALID_SERVER_NAME.test(serverName)) {
    p.log.error(
      `Invalid server name: "${serverName}". Must be alphanumeric with dots, hyphens, or underscores.`,
    );
    process.exit(1);
  }

  p.log.info(`Server name: ${serverName}`);

  // Detect apps and select using shared helper
  const resolved = resolveAppsFromFlags(options);

  let selectedApps: DetectedApp[];

  if (!resolved) {
    p.outro("Done");
    return;
  }

  if (resolved.apps.length === 0 && !isNonInteractive) {
    const detected = resolved.allApps.filter((app) => app.exists);
    if (detected.length === 0) {
      p.log.warn("No AI applications detected.");
      p.outro("Done");
      return;
    }
    const selected = await p.multiselect({
      message: "Select apps to configure:",
      options: detected.map((app) => ({
        label: app.name,
        value: app,
        hint: shortenPath(app.configPath),
      })),
      initialValues: detected,
      required: true,
    });

    if (p.isCancel(selected)) {
      p.cancel("Operation cancelled.");
      process.exit(0);
    }
    selectedApps = selected;
  } else if (resolved.apps.length === 0) {
    p.log.error(new AppNotDetectedError().format());
    p.outro("Done");
    return;
  } else {
    selectedApps = resolved.apps;
  }

  // Scope selection using shared helper
  const { apps: scopedApps, scope: chosenScope } = await resolveScope(
    selectedApps,
    options,
    isNonInteractive,
  );
  selectedApps = scopedApps;

  // Generate and merge
  if (options.dryRun) {
    p.log.step("Dry run — showing what would be written:\n");
  }

  const results: { app: DetectedApp; ok: boolean; error?: string }[] = [];

  for (const app of selectedApps) {
    try {
      const generator = getGenerator(app.id);
      const generatedConfig = generator.generate(serverName, config);

      if (options.dryRun) {
        const serialized = generator.serialize(generatedConfig);
        p.note(`File: ${shortenPath(app.configPath)}\n\n${serialized}`, app.name);
        results.push({ app, ok: true });
      } else {
        const merged = mergeServerIntoConfig(app.configPath, generatedConfig);
        writeConfigFile(app.configPath, merged);
        results.push({ app, ok: true });
      }
    } catch (err) {
      results.push({ app, ok: false, error: formatError(err) });
    }
  }

  // Track (with source info)
  if (!options.dryRun) {
    const successResults = results.filter((r) => r.ok);
    const successAppIds = successResults.map((r) => r.app.id);
    if (successAppIds.length > 0) {
      const scopes: Record<string, "project" | "global"> = {};
      for (const r of successResults) {
        scopes[r.app.id] = r.app.supportsBothScopes ? chosenScope : "project";
      }
      trackInstallation(serverName, successAppIds, [], undefined, scopes);
    }
  }

  if (options.json) {
    const output = {
      server: serverName,
      name: serverName,
      source: options.fromNpm ? "npm" : options.fromPypi ? "pypi" : "url",
      dryRun: options.dryRun ?? false,
      apps: results.map((r) => ({
        id: r.app.id,
        name: r.app.name,
        configPath: r.app.configPath,
        ok: r.ok,
        ...(r.error ? { error: r.error } : {}),
      })),
    };
    console.log(JSON.stringify(output, null, 2));
    return;
  }

  for (const r of results) {
    if (r.ok) {
      p.log.success(`${r.app.name}: ${shortenPath(r.app.configPath)}`);
    } else {
      p.log.error(`${r.app.name}: ${r.error}`);
    }
  }

  const action = options.dryRun ? "would be configured" : "has been configured";
  p.outro(`"${serverName}" ${action}.`);
}

function printManualConfig(entry: InternalRegistryEntry, config: LooseServerConfigType): void {
  const canonical = {
    mcpServers: {
      [entry.id]: config,
    },
  };
  p.note(JSON.stringify(canonical, null, 2), "Canonical config");
}

/**
 * Heuristic to detect if an environment variable name likely holds a secret.
 */
/**
 * Heuristic to detect if an environment variable name likely holds a secret.
 */
const SENSITIVE_PATTERNS = /TOKEN|KEY|SECRET|PASSWORD|CREDENTIAL|AUTH|PAT|PRIVATE/i;

/**
 * Keys that could cause prototype pollution if used as server names.
 */
const UNSAFE_KEYS = new Set(["__proto__", "constructor", "prototype"]);

/**
 * Pattern for valid server names — alphanumeric, dots, hyphens, underscores.
 */
const VALID_SERVER_NAME = /^[a-z0-9][a-z0-9._-]*$/i;

/**
 * Environment variable names that could be dangerous if injected via
 * registry entries into config files.
 */
const DANGEROUS_ENV_VARS = new Set([
  "PATH",
  "LD_PRELOAD",
  "LD_LIBRARY_PATH",
  "DYLD_INSERT_LIBRARIES",
  "DYLD_LIBRARY_PATH",
  "NODE_OPTIONS",
  "NODE_PATH",
  "PYTHONPATH",
  "PYTHONSTARTUP",
  "RUBYOPT",
  "PERL5OPT",
  "SHELL",
  "COMSPEC",
  "HOME",
  "USERPROFILE",
  "SYSTEMROOT",
]);
