import { Command } from 'commander';
import { type ApiClient, ApiError } from '../api-client.js';
import { resolveNameOrId } from './shared.js';
import { parseRoleBinding } from './rbac-bindings.js';
export interface CreateCommandDeps {
  client: ApiClient;
  log: (...args: unknown[]) => void;
}

function collect(value: string, prev: string[]): string[] {
  return [...prev, value];
}

/**
 * Parse a `--ttl` value.
 *
 * - `"never"` → null (no expiry)
 * - `"30d"`, `"12h"`, `"2w"`, `"90m"`, `"60s"` → ISO8601 string relative to now
 * - An ISO8601 datetime → returned as-is
 */
function parseTtl(value: string): string | null {
  const trimmed = value.trim();
  if (trimmed.toLowerCase() === 'never') return null;
  const match = trimmed.match(/^(\d+)([smhdw])$/i);
  if (match) {
    const amount = Number(match[1]);
    const unit = match[2]!.toLowerCase();
    const multipliers: Record<string, number> = {
      s: 1000,
      m: 60 * 1000,
      h: 3600 * 1000,
      d: 86400 * 1000,
      w: 7 * 86400 * 1000,
    };
    return new Date(Date.now() + amount * multipliers[unit]!).toISOString();
  }
  // Try to parse as ISO8601
  const parsed = new Date(trimmed);
  if (isNaN(parsed.getTime())) {
    throw new Error(`Invalid --ttl '${value}'. Expected 'never', a duration like '30d' / '12h', or an ISO8601 datetime.`);
  }
  return parsed.toISOString();
}

interface ServerEnvEntry {
  name: string;
  value?: string;
  valueFrom?: { secretRef: { name: string; key: string } };
}

function parseServerEnv(entries: string[]): ServerEnvEntry[] {
  return entries.map((entry) => {
    const eqIdx = entry.indexOf('=');
    if (eqIdx === -1) {
      throw new Error(`Invalid env format '${entry}'. Expected KEY=value or KEY=secretRef:SECRET:KEY`);
    }
    const envName = entry.slice(0, eqIdx);
    const rhs = entry.slice(eqIdx + 1);

    if (rhs.startsWith('secretRef:')) {
      const parts = rhs.split(':');
      if (parts.length !== 3) {
        throw new Error(`Invalid secretRef format '${entry}'. Expected KEY=secretRef:SECRET_NAME:SECRET_KEY`);
      }
      return {
        name: envName,
        valueFrom: { secretRef: { name: parts[1]!, key: parts[2]! } },
      };
    }

    return { name: envName, value: rhs };
  });
}

function parseEnvEntries(entries: string[]): Record<string, string> {
  const result: Record<string, string> = {};
  for (const entry of entries) {
    const eqIdx = entry.indexOf('=');
    if (eqIdx === -1) {
      throw new Error(`Invalid env format '${entry}'. Expected KEY=value`);
    }
    result[entry.slice(0, eqIdx)] = entry.slice(eqIdx + 1);
  }
  return result;
}

export function createCreateCommand(deps: CreateCommandDeps): Command {
  const { client, log } = deps;

  const cmd = new Command('create')
    .description('Create a resource (server, secret, secretbackend, llm, agent, project, user, group, rbac, serverattachment, prompt)');

  // --- create server ---
  cmd.command('server')
    .description('Create an MCP server definition')
    .argument('<name>', 'Server name (lowercase, hyphens allowed)')
    .option('-d, --description <text>', 'Server description')
    .option('--package-name <name>', 'Package name (npm, PyPI, Go module, etc.)')
    .option('--runtime <type>', 'Package runtime (node, python, go — default: node)')
    .option('--docker-image <image>', 'Docker image')
    .option('--transport <type>', 'Transport type (STDIO, SSE, STREAMABLE_HTTP)')
    .option('--repository-url <url>', 'Source repository URL')
    .option('--external-url <url>', 'External endpoint URL')
    .option('--command <arg>', 'Command argument (repeat for multiple)', collect, [])
    .option('--container-port <port>', 'Container port number')
    .option('--replicas <count>', 'Number of replicas')
    .option('--env <entry>', 'Env var: KEY=value (inline) or KEY=secretRef:SECRET:KEY (secret ref, repeat for multiple)', collect, [])
    .option('--from-template <name>', 'Create from template (name or name:version)')
    .option('--env-from-secret <secret>', 'Map template env vars from a secret')
    .option('--force', 'Update if already exists')
    .action(async (name: string, opts) => {
      let base: Record<string, unknown> = {};

      // If --from-template, fetch template and use as base
      if (opts.fromTemplate) {
        const tplRef = opts.fromTemplate as string;
        const [tplName, tplVersion] = tplRef.includes(':')
          ? [tplRef.slice(0, tplRef.indexOf(':')), tplRef.slice(tplRef.indexOf(':') + 1)]
          : [tplRef, undefined];

        const templates = await client.get<Array<Record<string, unknown>>>(`/api/v1/templates?name=${encodeURIComponent(tplName)}`);
        let template: Record<string, unknown> | undefined;
        if (tplVersion) {
          template = templates.find((t) => t.name === tplName && t.version === tplVersion);
          if (!template) throw new Error(`Template '${tplName}' version '${tplVersion}' not found`);
        } else {
          template = templates.find((t) => t.name === tplName);
          if (!template) throw new Error(`Template '${tplName}' not found`);
        }

        // Copy template fields as base (strip template-only, internal, and null fields)
        const { id: _id, createdAt: _c, updatedAt: _u, version: _v, name: _n, ...tplFields } = template;
        base = {};
        for (const [k, v] of Object.entries(tplFields)) {
          if (v !== null && v !== undefined) base[k] = v;
        }

        // Convert template env (description/required) to server env (name/value/valueFrom)
        const tplEnv = template.env as Array<{ name: string; description?: string; required?: boolean; defaultValue?: string }> | undefined;
        if (tplEnv && tplEnv.length > 0) {
          if (opts.envFromSecret) {
            // --env-from-secret: map all template env vars from the specified secret
            const secretName = opts.envFromSecret as string;
            const secrets = await client.get<Array<{ name: string; data: Record<string, string> }>>('/api/v1/secrets');
            const secret = secrets.find((s) => s.name === secretName);
            if (!secret) throw new Error(`Secret '${secretName}' not found`);

            const missing = tplEnv
              .filter((e) => e.required !== false && !(e.name in secret.data))
              .map((e) => e.name);
            if (missing.length > 0) {
              throw new Error(
                `Secret '${secretName}' is missing required keys: ${missing.join(', ')}\n` +
                `Secret has: ${Object.keys(secret.data).join(', ')}`,
              );
            }

            base.env = tplEnv.map((e) => {
              if (e.name in secret.data) {
                return { name: e.name, valueFrom: { secretRef: { name: secretName, key: e.name } } };
              }
              return { name: e.name, value: e.defaultValue ?? '' };
            });
            log(`Mapped ${tplEnv.filter((e) => e.name in secret.data).length} env var(s) from secret '${secretName}'`);
          } else {
            base.env = tplEnv.map((e) => ({ name: e.name, value: e.defaultValue ?? '' }));
          }
        }

        // Track template origin
        base.templateName = tplName;
        base.templateVersion = (template.version as string) ?? '1.0.0';
      }

      // Build body: template base → CLI overrides (last wins)
      const body: Record<string, unknown> = {
        ...base,
        name,
      };
      if (opts.description !== undefined) body.description = opts.description;
      if (opts.transport) body.transport = opts.transport;
      if (opts.replicas) body.replicas = parseInt(opts.replicas, 10);
      if (opts.packageName) body.packageName = opts.packageName;
      if (opts.runtime) body.runtime = opts.runtime;
      if (opts.dockerImage) body.dockerImage = opts.dockerImage;
      if (opts.repositoryUrl) body.repositoryUrl = opts.repositoryUrl;
      if (opts.externalUrl) body.externalUrl = opts.externalUrl;
      if (opts.command.length > 0) body.command = opts.command;
      if (opts.containerPort) body.containerPort = parseInt(opts.containerPort, 10);
      if (opts.env.length > 0) {
        // Merge: CLI env entries override template env entries by name
        const cliEnv = parseServerEnv(opts.env);
        const existing = (body.env as ServerEnvEntry[] | undefined) ?? [];
        const merged = [...existing];
        for (const entry of cliEnv) {
          const idx = merged.findIndex((e) => e.name === entry.name);
          if (idx >= 0) {
            merged[idx] = entry;
          } else {
            merged.push(entry);
          }
        }
        body.env = merged;
      }

      // Defaults when no template
      if (!opts.fromTemplate) {
        if (body.description === undefined) body.description = '';
        if (!body.transport) body.transport = 'STDIO';
        if (!body.replicas) body.replicas = 1;
      }

      try {
        const server = await client.post<{ id: string; name: string }>('/api/v1/servers', body);
        log(`server '${server.name}' created (id: ${server.id})`);
      } catch (err) {
        if (err instanceof ApiError && err.status === 409 && opts.force) {
          const existing = (await client.get<Array<{ id: string; name: string }>>('/api/v1/servers')).find((s) => s.name === name);
          if (!existing) throw err;
          const { name: _n, ...updateBody } = body;
          await client.put(`/api/v1/servers/${existing.id}`, updateBody);
          log(`server '${name}' updated (id: ${existing.id})`);
        } else {
          throw err;
        }
      }
    });

  // --- create secret ---
  cmd.command('secret')
    .description('Create a secret')
    .argument('<name>', 'Secret name (lowercase, hyphens allowed)')
    .option('--data <entry>', 'Secret data KEY=value (repeat for multiple)', collect, [])
    .option('--force', 'Update if already exists')
    .action(async (name: string, opts) => {
      const data = parseEnvEntries(opts.data);
      try {
        const secret = await client.post<{ id: string; name: string }>('/api/v1/secrets', {
          name,
          data,
        });
        log(`secret '${secret.name}' created (id: ${secret.id})`);
      } catch (err) {
        if (err instanceof ApiError && err.status === 409 && opts.force) {
          const existing = (await client.get<Array<{ id: string; name: string }>>('/api/v1/secrets')).find((s) => s.name === name);
          if (!existing) throw err;
          await client.put(`/api/v1/secrets/${existing.id}`, { data });
          log(`secret '${name}' updated (id: ${existing.id})`);
        } else {
          throw err;
        }
      }
    });

  // --- create llm ---
  cmd.command('llm')
    .description('Register a server-managed LLM (anthropic, openai, vllm, ollama, deepseek, gemini-cli)')
    .argument('<name>', 'LLM name (lowercase alphanumeric with hyphens)')
    .requiredOption('--type <type>', 'Provider type (anthropic, openai, deepseek, vllm, ollama, gemini-cli)')
    .requiredOption('--model <model>', 'Model identifier (e.g. claude-3-5-sonnet-20241022)')
    .option('--url <url>', 'Endpoint URL (empty = provider default)')
    .option('--tier <tier>', 'Tier: fast or heavy', 'fast')
    .option('--description <text>', 'Description')
    .option('--api-key-ref <ref>', 'API key reference in SECRET/KEY form (e.g. anthropic-key/token)')
    .option('--extra <entry>', 'Extra config key=value (repeat)', collect, [])
    .option('--force', 'Update if already exists')
    .option('--skip-auth-check', 'Skip the upstream auth probe (for offline registration before infra exists)')
    .action(async (name: string, opts) => {
      const body: Record<string, unknown> = {
        name,
        type: opts.type,
        model: opts.model,
        tier: opts.tier,
      };
      if (opts.url) body.url = opts.url;
      if (opts.description !== undefined) body.description = opts.description;
      if (opts.apiKeyRef) {
        const slashIdx = (opts.apiKeyRef as string).indexOf('/');
        if (slashIdx < 1) throw new Error(`Invalid --api-key-ref '${opts.apiKeyRef as string}'. Expected SECRET_NAME/KEY_NAME`);
        body.apiKeyRef = {
          name: (opts.apiKeyRef as string).slice(0, slashIdx),
          key: (opts.apiKeyRef as string).slice(slashIdx + 1),
        };
      }
      if (opts.extra && (opts.extra as string[]).length > 0) {
        const extra: Record<string, unknown> = {};
        for (const entry of opts.extra as string[]) {
          const eqIdx = entry.indexOf('=');
          if (eqIdx === -1) throw new Error(`Invalid --extra '${entry}'. Expected key=value`);
          extra[entry.slice(0, eqIdx)] = entry.slice(eqIdx + 1);
        }
        body.extraConfig = extra;
      }
      // _skipAuthCheck is a side-channel field consumed (and stripped) by the
      // mcpd route — it never makes it into the Llm row. mcpd defaults to
      // running an auth probe at create/update time so wrong tokens fail fast
      // with a 422 instead of silently 502'ing on first chat.
      if (opts.skipAuthCheck === true) body._skipAuthCheck = true;

      try {
        const row = await client.post<{ id: string; name: string }>('/api/v1/llms', body);
        log(`llm '${row.name}' created (id: ${row.id})`);
      } catch (err) {
        if (err instanceof ApiError && err.status === 409 && opts.force) {
          const existing = (await client.get<Array<{ id: string; name: string }>>('/api/v1/llms')).find((l) => l.name === name);
          if (!existing) throw err;
          const { name: _n, type: _t, ...updateBody } = body;
          await client.put(`/api/v1/llms/${existing.id}`, updateBody);
          log(`llm '${name}' updated (id: ${existing.id})`);
        } else {
          throw err;
        }
      }
    });

  // --- create agent ---
  cmd.command('agent')
    .description('Create an Agent (LLM persona pinned to an Llm, optionally attached to a Project)')
    .argument('<name>', 'Agent name (lowercase alphanumeric with hyphens)')
    .requiredOption('--llm <name>', 'Pinned Llm (see `mcpctl get llms`)')
    .option('--project <name>', 'Attach to this Project (optional)')
    .option('--description <text>', 'Description (shown in MCP tools/list)')
    .option('--system-prompt <text>', 'System prompt (persona)')
    .option('--system-prompt-file <path>', 'Read system prompt from a file')
    .option('--proxy-model <name>', 'Optional proxyModel name override (informational)')
    .option('--default-temperature <n>', 'Default sampling temperature', parseFloat)
    .option('--default-top-p <n>', 'Default top_p', parseFloat)
    .option('--default-top-k <n>', 'Default top_k', (s: string) => parseInt(s, 10))
    .option('--default-max-tokens <n>', 'Default max_tokens', (s: string) => parseInt(s, 10))
    .option('--default-seed <n>', 'Default seed', (s: string) => parseInt(s, 10))
    .option('--default-stop <text>', 'Default stop sequence (repeat for multiple)', collect, [])
    .option('--default-extra <kv>', 'Default provider-specific knob k=v (repeat)', collect, [])
    .option('--default-params-file <path>', 'Read defaultParams from a JSON file')
    .option('--force', 'Update if already exists')
    .action(async (name: string, opts) => {
      const body: Record<string, unknown> = {
        name,
        llm: { name: opts.llm },
      };
      if (opts.project) body.project = { name: opts.project };
      if (opts.description !== undefined) body.description = opts.description;

      let systemPrompt = opts.systemPrompt as string | undefined;
      if (systemPrompt === undefined && opts.systemPromptFile !== undefined) {
        const fs = await import('node:fs/promises');
        systemPrompt = (await fs.readFile(opts.systemPromptFile as string, 'utf-8')).trim();
      }
      if (systemPrompt !== undefined) body.systemPrompt = systemPrompt;
      if (opts.proxyModel !== undefined) body.proxyModelName = opts.proxyModel;

      let defaults: Record<string, unknown> = {};
      if (opts.defaultParamsFile !== undefined) {
        const fs = await import('node:fs/promises');
        defaults = JSON.parse(await fs.readFile(opts.defaultParamsFile as string, 'utf-8')) as Record<string, unknown>;
      }
      if (opts.defaultTemperature !== undefined) defaults.temperature = opts.defaultTemperature;
      if (opts.defaultTopP !== undefined) defaults.top_p = opts.defaultTopP;
      if (opts.defaultTopK !== undefined) defaults.top_k = opts.defaultTopK;
      if (opts.defaultMaxTokens !== undefined) defaults.max_tokens = opts.defaultMaxTokens;
      if (opts.defaultSeed !== undefined) defaults.seed = opts.defaultSeed;
      if (opts.defaultStop && (opts.defaultStop as string[]).length > 0) {
        defaults.stop = (opts.defaultStop as string[]).length === 1 ? (opts.defaultStop as string[])[0] : opts.defaultStop;
      }
      if (opts.defaultExtra && (opts.defaultExtra as string[]).length > 0) {
        const extra: Record<string, unknown> = (defaults.extra as Record<string, unknown> | undefined) ?? {};
        for (const kv of opts.defaultExtra as string[]) {
          const eq = (kv as string).indexOf('=');
          if (eq < 1) throw new Error(`--default-extra '${kv}' must be key=value`);
          extra[(kv as string).slice(0, eq)] = (kv as string).slice(eq + 1);
        }
        defaults.extra = extra;
      }
      if (Object.keys(defaults).length > 0) body.defaultParams = defaults;

      try {
        const row = await client.post<{ id: string; name: string }>('/api/v1/agents', body);
        log(`agent '${row.name}' created (id: ${row.id})`);
      } catch (err) {
        if (err instanceof ApiError && err.status === 409 && opts.force) {
          const existing = (await client.get<Array<{ id: string; name: string }>>('/api/v1/agents')).find((a) => a.name === name);
          if (!existing) throw err;
          const { name: _n, ...updateBody } = body;
          await client.put(`/api/v1/agents/${existing.id}`, updateBody);
          log(`agent '${name}' updated (id: ${existing.id})`);
        } else {
          throw err;
        }
      }
    });

  // --- create secretbackend ---
  cmd.command('secretbackend')
    .alias('sb')
    .description('Create a secret backend (plaintext, openbao)')
    .argument('<name>', 'Backend name (lowercase, hyphens allowed)')
    .requiredOption('--type <type>', 'Backend type (plaintext, openbao)')
    .option('--description <text>', 'Description')
    .option('--default', 'Promote this backend to default (atomically demotes the current one)')
    .option('--url <url>', 'openbao: vault URL (e.g. http://bao.example:8200)')
    .option('--namespace <ns>', 'openbao: X-Vault-Namespace header value')
    .option('--mount <mount>', 'openbao: KV v2 mount point (default: secret)')
    .option('--path-prefix <prefix>', 'openbao: path prefix under mount (default: mcpctl)')
    .option('--auth <method>', "openbao: auth method — 'token' (default) or 'kubernetes'")
    .option('--token-secret <ref>', 'openbao token auth: token secret reference in SECRET/KEY form (e.g. bao-creds/token)')
    .option('--role <name>', "openbao kubernetes auth: vault role to login as (e.g. 'mcpctl')")
    .option('--auth-mount <path>', "openbao kubernetes auth: vault auth method mount path (default: 'kubernetes')")
    .option('--sa-token-path <path>', "openbao kubernetes auth: filesystem path to projected SA token (default: '/var/run/secrets/kubernetes.io/serviceaccount/token')")
    .option('--config <entry>', 'Extra config as key=value (repeat for multiple)', collect, [])
    .option('--wizard', 'Interactive wizard (openbao only): provision policy + token role, mint token, store on mcpd, suggest migration')
    .option('--setup-token <token>', "openbao wizard: OpenBao token with provisioning perms (policy write + auth/token admin). Root works; a scoped SA token works too. Prompted if omitted. Used only for provisioning; NEVER persisted.")
    .option('--policy-name <name>', "openbao wizard: name for the policy created on OpenBao (default: 'app-mcpd')")
    .option('--token-role <name>', "openbao wizard: name for the token role created on OpenBao (default: 'app-mcpd-role')")
    .option('--no-promote-default', 'openbao wizard: do not promote this backend to default after creation')
    .option('--force', 'Update if already exists')
    .action(async (name: string, opts) => {
      const type = opts.type as string;
      // Wizard path — delegates to create-secretbackend-wizard.ts.
      if (opts.wizard === true) {
        if (type !== 'openbao') {
          throw new Error(`--wizard is only supported for --type openbao (got '${type}')`);
        }
        const { runSecretBackendOpenbaoWizard } = await import('./create-secretbackend-wizard.js');
        const wizardInput: Parameters<typeof runSecretBackendOpenbaoWizard>[0] = { name };
        if (opts.url !== undefined) wizardInput.url = opts.url as string;
        if (opts.setupToken !== undefined) wizardInput.setupToken = opts.setupToken as string;
        if (opts.mount !== undefined) wizardInput.mount = opts.mount as string;
        if (opts.pathPrefix !== undefined) wizardInput.pathPrefix = opts.pathPrefix as string;
        if (opts.policyName !== undefined) wizardInput.policyName = opts.policyName as string;
        if (opts.tokenRole !== undefined) wizardInput.tokenRole = opts.tokenRole as string;
        // `--no-promote-default` → opts.promoteDefault === false (commander negated flag)
        if (opts.promoteDefault !== undefined) wizardInput.promoteToDefault = opts.promoteDefault as boolean;
        await runSecretBackendOpenbaoWizard(wizardInput, { client, log });
        return;
      }
      const config: Record<string, unknown> = {};

      if (type === 'openbao') {
        if (!opts.url) throw new Error('--url is required for openbao backend');
        const auth = (opts.auth as string | undefined) ?? 'token';
        if (auth !== 'token' && auth !== 'kubernetes') {
          throw new Error(`--auth must be 'token' or 'kubernetes' (got '${auth}')`);
        }
        config.url = opts.url;
        config.auth = auth;

        if (auth === 'token') {
          if (!opts.tokenSecret) throw new Error('--token-secret is required for openbao token auth (format: SECRET/KEY)');
          const slashIdx = (opts.tokenSecret as string).indexOf('/');
          if (slashIdx < 1) throw new Error(`Invalid --token-secret '${opts.tokenSecret as string}'. Expected SECRET_NAME/KEY_NAME`);
          config.tokenSecretRef = {
            name: (opts.tokenSecret as string).slice(0, slashIdx),
            key: (opts.tokenSecret as string).slice(slashIdx + 1),
          };
        } else {
          if (!opts.role) throw new Error("--role is required for openbao kubernetes auth (the vault role bound to this pod's ServiceAccount)");
          config.role = opts.role;
          if (opts.authMount) config.authMount = opts.authMount;
          if (opts.saTokenPath) config.serviceAccountTokenPath = opts.saTokenPath;
        }

        if (opts.namespace) config.namespace = opts.namespace;
        if (opts.mount) config.mount = opts.mount;
        if (opts.pathPrefix) config.pathPrefix = opts.pathPrefix;
      }

      // Extra config key=value pairs (overwrite/extend above)
      for (const entry of opts.config as string[]) {
        const eqIdx = entry.indexOf('=');
        if (eqIdx === -1) throw new Error(`Invalid --config '${entry}'. Expected key=value`);
        config[entry.slice(0, eqIdx)] = entry.slice(eqIdx + 1);
      }

      const body: Record<string, unknown> = { name, type, config };
      if (opts.description !== undefined) body.description = opts.description;
      if (opts.default) body.isDefault = true;

      try {
        const row = await client.post<{ id: string; name: string }>('/api/v1/secretbackends', body);
        log(`secretbackend '${row.name}' created (id: ${row.id})`);
        if (opts.default) log(`  promoted to default backend`);
      } catch (err) {
        if (err instanceof ApiError && err.status === 409 && opts.force) {
          const existing = (await client.get<Array<{ id: string; name: string }>>('/api/v1/secretbackends')).find((b) => b.name === name);
          if (!existing) throw err;
          const updateBody: Record<string, unknown> = { config };
          if (opts.description !== undefined) updateBody.description = opts.description;
          if (opts.default) updateBody.isDefault = true;
          await client.put(`/api/v1/secretbackends/${existing.id}`, updateBody);
          log(`secretbackend '${name}' updated (id: ${existing.id})`);
        } else {
          throw err;
        }
      }
    });

  // --- create project ---
  cmd.command('project')
    .description('Create a project')
    .argument('<name>', 'Project name')
    .option('-d, --description <text>', 'Project description', '')
    .option('--proxy-model <name>', 'Plugin name (default, content-pipeline, gate, none)')
    .option('--prompt <text>', 'Project-level prompt / instructions for the LLM')
    .option('--llm <name>', "Name of an Llm resource (see 'mcpctl get llms'), or 'none' to disable")
    .option('--llm-model <model>', 'Override the model string for this project (defaults to the Llm\'s own model)')
    .option('--gated', '[deprecated: use --proxy-model default]')
    .option('--no-gated', '[deprecated: use --proxy-model content-pipeline]')
    .option('--server <name>', 'Server name (repeat for multiple)', collect, [])
    .option('--force', 'Update if already exists')
    .action(async (name: string, opts) => {
      const body: Record<string, unknown> = {
        name,
        description: opts.description,
      };
      if (opts.prompt) body.prompt = opts.prompt;
      if (opts.proxyModel) {
        body.proxyModel = opts.proxyModel;
      } else if (opts.gated === false) {
        // Backward compat: --no-gated → proxyModel: content-pipeline
        body.proxyModel = 'content-pipeline';
      }
      // Pass gated for backward compat with older mcpd
      if (opts.gated !== undefined) body.gated = opts.gated as boolean;
      if (opts.server.length > 0) body.servers = opts.server;
      if (opts.llm) body.llmProvider = opts.llm;
      if (opts.llmModel) body.llmModel = opts.llmModel;

      try {
        const project = await client.post<{ id: string; name: string }>('/api/v1/projects', body);
        log(`project '${project.name}' created (id: ${project.id})`);
      } catch (err) {
        if (err instanceof ApiError && err.status === 409 && opts.force) {
          const existing = (await client.get<Array<{ id: string; name: string }>>('/api/v1/projects')).find((p) => p.name === name);
          if (!existing) throw err;
          const { name: _n, ...updateBody } = body;
          await client.put(`/api/v1/projects/${existing.id}`, updateBody);
          log(`project '${name}' updated (id: ${existing.id})`);
        } else {
          throw err;
        }
      }
    });

  // --- create user ---
  cmd.command('user')
    .description('Create a user')
    .argument('<email>', 'User email address')
    .option('--password <pass>', 'User password')
    .option('--name <name>', 'User display name')
    .option('--force', 'Update if already exists')
    .action(async (email: string, opts) => {
      if (!opts.password) {
        throw new Error('--password is required');
      }
      const body: Record<string, unknown> = {
        email,
        password: opts.password,
      };
      if (opts.name) body.name = opts.name;

      try {
        const user = await client.post<{ id: string; email: string }>('/api/v1/users', body);
        log(`user '${user.email}' created (id: ${user.id})`);
      } catch (err) {
        if (err instanceof ApiError && err.status === 409 && opts.force) {
          const existing = (await client.get<Array<{ id: string; email: string }>>('/api/v1/users')).find((u) => u.email === email);
          if (!existing) throw err;
          const { email: _e, ...updateBody } = body;
          await client.put(`/api/v1/users/${existing.id}`, updateBody);
          log(`user '${email}' updated (id: ${existing.id})`);
        } else {
          throw err;
        }
      }
    });

  // --- create group ---
  cmd.command('group')
    .description('Create a group')
    .argument('<name>', 'Group name')
    .option('--description <text>', 'Group description')
    .option('--member <email>', 'Member email (repeat for multiple)', collect, [])
    .option('--force', 'Update if already exists')
    .action(async (name: string, opts) => {
      const body: Record<string, unknown> = {
        name,
        members: opts.member,
      };
      if (opts.description) body.description = opts.description;

      try {
        const group = await client.post<{ id: string; name: string }>('/api/v1/groups', body);
        log(`group '${group.name}' created (id: ${group.id})`);
      } catch (err) {
        if (err instanceof ApiError && err.status === 409 && opts.force) {
          const existing = (await client.get<Array<{ id: string; name: string }>>('/api/v1/groups')).find((g) => g.name === name);
          if (!existing) throw err;
          const { name: _n, ...updateBody } = body;
          await client.put(`/api/v1/groups/${existing.id}`, updateBody);
          log(`group '${name}' updated (id: ${existing.id})`);
        } else {
          throw err;
        }
      }
    });

  // --- create rbac ---
  cmd.command('rbac')
    .description('Create an RBAC binding definition')
    .argument('<name>', 'RBAC binding name')
    .option('--subject <entry>', 'Subject as Kind:name (repeat for multiple)', collect, [])
    .option(
      '--roleBindings <entry>',
      'Role binding as key:value pairs, e.g. "role:view,resource:servers" or "role:view,resource:servers,name:my-ha" or "action:logs" (repeat for multiple)',
      collect,
      [],
    )
    .option('--force', 'Update if already exists')
    .action(async (name: string, opts) => {
      const subjects = (opts.subject as string[]).map((entry: string) => {
        const colonIdx = entry.indexOf(':');
        if (colonIdx === -1) {
          throw new Error(`Invalid subject format '${entry}'. Expected Kind:name (e.g. User:alice@example.com)`);
        }
        return { kind: entry.slice(0, colonIdx), name: entry.slice(colonIdx + 1) };
      });

      const roleBindings = (opts.roleBindings as string[]).map((entry: string) => parseRoleBinding(entry));

      const body: Record<string, unknown> = {
        name,
        subjects,
        roleBindings,
      };

      try {
        const rbac = await client.post<{ id: string; name: string }>('/api/v1/rbac', body);
        log(`rbac '${rbac.name}' created (id: ${rbac.id})`);
      } catch (err) {
        if (err instanceof ApiError && err.status === 409 && opts.force) {
          const existing = (await client.get<Array<{ id: string; name: string }>>('/api/v1/rbac')).find((r) => r.name === name);
          if (!existing) throw err;
          const { name: _n, ...updateBody } = body;
          await client.put(`/api/v1/rbac/${existing.id}`, updateBody);
          log(`rbac '${name}' updated (id: ${existing.id})`);
        } else {
          throw err;
        }
      }
    });

  // --- create mcptoken ---
  cmd.command('mcptoken')
    .description('Create a project-scoped API token for HTTP-mode mcplocal. The raw token is printed once.')
    .argument('<name>', 'Token name (unique within a project)')
    .requiredOption('-p, --project <name>', 'Project this token is bound to')
    .option('--rbac <mode>', "Base RBAC: 'empty' (default, no bindings) or 'clone' (snapshot creator's perms)", 'empty')
    .option(
      '--bind <entry>',
      'Additional role binding as key:value pairs, e.g. "role:view,resource:servers" or "action:logs" (repeat for multiple). Creator perms are the ceiling.',
      collect,
      [],
    )
    .option('--ttl <duration>', "Expiry: '30d', '12h', 'never', or an ISO8601 datetime")
    .option('--description <text>', 'Freeform description')
    .option('--force', 'Revoke any existing active token with this name, then create a new one')
    .action(async (name: string, opts) => {
      // Resolve project name → id (mcpd's create route accepts either, but resolve client-side for clearer errors)
      const projectId = await resolveNameOrId(client, 'projects', opts.project as string);

      const bindings = (opts.bind as string[]).map((entry: string) => parseRoleBinding(entry));

      const rbacMode = (opts.rbac as string).toLowerCase();
      if (rbacMode !== 'empty' && rbacMode !== 'clone') {
        throw new Error(`--rbac must be 'empty' or 'clone' (got '${opts.rbac as string}')`);
      }

      let expiresAt: string | null | undefined;
      if (opts.ttl !== undefined) {
        expiresAt = parseTtl(opts.ttl as string);
      }

      const body: Record<string, unknown> = {
        name,
        projectId,
        rbacMode,
        bindings,
      };
      if (expiresAt !== undefined) body.expiresAt = expiresAt;
      if (opts.description !== undefined) body.description = opts.description;

      type Created = {
        id: string;
        name: string;
        projectName: string;
        tokenPrefix: string;
        token: string;
        expiresAt: string | null;
      };

      const doCreate = async (): Promise<Created> => client.post<Created>('/api/v1/mcptokens', body);

      let created: Created;
      try {
        created = await doCreate();
      } catch (err) {
        if (err instanceof ApiError && err.status === 409 && opts.force) {
          // Find the existing active token by name+project and revoke it, then retry.
          const existing = (await client.get<Array<{ id: string; name: string }>>(
            `/api/v1/mcptokens?projectName=${encodeURIComponent(opts.project as string)}`,
          )).find((r) => r.name === name);
          if (!existing) throw err;
          await client.post(`/api/v1/mcptokens/${existing.id}/revoke`, {});
          created = await doCreate();
        } else {
          throw err;
        }
      }

      log(`mcptoken '${created.name}' created (project: ${created.projectName}, id: ${created.id})`);
      log('');
      log('Copy this token now — it will NOT be shown again:');
      log('');
      log(`  ${created.token}`);
      log('');
      log(`Export it with:  export MCPCTL_TOKEN=${created.token}`);
    });

  // --- create prompt ---
  cmd.command('prompt')
    .description('Create an approved prompt')
    .argument('<name>', 'Prompt name (lowercase alphanumeric with hyphens)')
    .option('-p, --project <name>', 'Project name to scope the prompt to')
    .option('--content <text>', 'Prompt content text')
    .option('--content-file <path>', 'Read prompt content from file')
    .option('--priority <number>', 'Priority 1-10 (default: 5, higher = more important)')
    .option('--link <target>', 'Link to MCP resource (format: project/server:uri)')
    .action(async (name: string, opts) => {
      let content = opts.content as string | undefined;
      if (opts.contentFile) {
        const fs = await import('node:fs/promises');
        content = await fs.readFile(opts.contentFile as string, 'utf-8');
      }
      // For linked prompts, auto-generate placeholder content if none provided
      if (!content && opts.link) {
        content = `Linked prompt — content fetched from ${opts.link as string}`;
      }
      if (!content) {
        throw new Error('--content or --content-file is required');
      }

      const body: Record<string, unknown> = { name, content };
      if (opts.project) {
        // Resolve project name to ID
        const projects = await client.get<Array<{ id: string; name: string }>>('/api/v1/projects');
        const project = projects.find((p) => p.name === opts.project);
        if (!project) throw new Error(`Project '${opts.project as string}' not found`);
        body.projectId = project.id;
      }
      if (opts.priority) {
        const priority = Number(opts.priority);
        if (isNaN(priority) || priority < 1 || priority > 10) {
          throw new Error('--priority must be a number between 1 and 10');
        }
        body.priority = priority;
      }
      if (opts.link) {
        body.linkTarget = opts.link;
      }

      const prompt = await client.post<{ id: string; name: string }>('/api/v1/prompts', body);
      log(`prompt '${prompt.name}' created (id: ${prompt.id})`);
    });

  // --- create serverattachment ---
  cmd.command('serverattachment')
    .alias('sa')
    .description('Attach a server to a project')
    .argument('<server>', 'Server name')
    .option('-p, --project <name>', 'Project name')
    .action(async (serverName: string, opts) => {
      const projectName = opts.project as string | undefined;
      if (!projectName) {
        throw new Error('--project is required. Usage: mcpctl create serverattachment <server> --project <name>');
      }
      const projectId = await resolveNameOrId(client, 'projects', projectName);
      await client.post(`/api/v1/projects/${projectId}/servers`, { server: serverName });
      log(`server '${serverName}' attached to project '${projectName}'`);
    });

  // --- create promptrequest ---
  cmd.command('promptrequest')
    .description('Create a prompt request (pending proposal that needs approval)')
    .argument('<name>', 'Prompt request name (lowercase alphanumeric with hyphens)')
    .option('-p, --project <name>', 'Project name to scope the prompt request to')
    .option('--content <text>', 'Prompt content text')
    .option('--content-file <path>', 'Read prompt content from file')
    .option('--priority <number>', 'Priority 1-10 (default: 5, higher = more important)')
    .action(async (name: string, opts) => {
      let content = opts.content as string | undefined;
      if (opts.contentFile) {
        const fs = await import('node:fs/promises');
        content = await fs.readFile(opts.contentFile as string, 'utf-8');
      }
      if (!content) {
        throw new Error('--content or --content-file is required');
      }

      const body: Record<string, unknown> = { name, content };
      if (opts.project) {
        body.project = opts.project;
      }
      if (opts.priority) {
        const priority = Number(opts.priority);
        if (isNaN(priority) || priority < 1 || priority > 10) {
          throw new Error('--priority must be a number between 1 and 10');
        }
        body.priority = priority;
      }

      const pr = await client.post<{ id: string; name: string }>(
        '/api/v1/promptrequests',
        body,
      );
      log(`prompt request '${pr.name}' created (id: ${pr.id})`);
      log(`  approve with: mcpctl approve promptrequest ${pr.name}`);
    });

  return cmd;
}
