import { Command } from 'commander';
import { readFileSync, readSync } from 'node:fs';
import yaml from 'js-yaml';
import { z } from 'zod';
import type { ApiClient } from '../api-client.js';

const HealthCheckSchema = z.object({
  tool: z.string().min(1),
  arguments: z.record(z.unknown()).default({}),
  intervalSeconds: z.number().int().min(5).max(3600).default(60),
  timeoutSeconds: z.number().int().min(1).max(120).default(10),
  failureThreshold: z.number().int().min(1).max(20).default(3),
});

const ServerEnvEntrySchema = z.object({
  name: z.string().min(1),
  value: z.string().optional(),
  valueFrom: z.object({
    secretRef: z.object({ name: z.string(), key: z.string() }),
  }).optional(),
});

const ServerSpecSchema = z.object({
  name: z.string().min(1),
  description: z.string().default(''),
  packageName: z.string().optional(),
  runtime: z.string().optional(),
  dockerImage: z.string().optional(),
  transport: z.enum(['STDIO', 'SSE', 'STREAMABLE_HTTP']).default('STDIO'),
  repositoryUrl: z.string().url().optional(),
  externalUrl: z.string().url().optional(),
  command: z.array(z.string()).optional(),
  containerPort: z.number().int().min(1).max(65535).optional(),
  replicas: z.number().int().min(0).max(10).default(1),
  env: z.array(ServerEnvEntrySchema).default([]),
  healthCheck: HealthCheckSchema.optional(),
});

const SecretSpecSchema = z.object({
  name: z.string().min(1),
  data: z.record(z.string()).default({}),
});

const TemplateEnvEntrySchema = z.object({
  name: z.string().min(1),
  description: z.string().optional(),
  required: z.boolean().optional(),
  defaultValue: z.string().optional(),
});

const TemplateSpecSchema = z.object({
  name: z.string().min(1),
  version: z.string().default('1.0.0'),
  description: z.string().default(''),
  packageName: z.string().optional(),
  runtime: z.string().optional(),
  dockerImage: z.string().optional(),
  transport: z.enum(['STDIO', 'SSE', 'STREAMABLE_HTTP']).default('STDIO'),
  repositoryUrl: z.string().optional(),
  externalUrl: z.string().optional(),
  command: z.array(z.string()).optional(),
  containerPort: z.number().int().min(1).max(65535).optional(),
  replicas: z.number().int().min(0).max(10).default(1),
  env: z.array(TemplateEnvEntrySchema).default([]),
  healthCheck: HealthCheckSchema.optional(),
});

const UserSpecSchema = z.object({
  email: z.string().email(),
  password: z.string().min(8),
  name: z.string().optional(),
});

const GroupSpecSchema = z.object({
  name: z.string().min(1),
  description: z.string().default(''),
  members: z.array(z.string().email()).default([]),
});

const RbacSubjectSchema = z.object({
  kind: z.enum(['User', 'Group', 'ServiceAccount']),
  name: z.string().min(1),
});

const RESOURCE_ALIASES: Record<string, string> = {
  server: 'servers', instance: 'instances', secret: 'secrets',
  project: 'projects', template: 'templates', user: 'users', group: 'groups',
  prompt: 'prompts', promptrequest: 'promptrequests',
};

const RbacRoleBindingSchema = z.union([
  z.object({
    role: z.enum(['edit', 'view', 'create', 'delete', 'run', 'expose']),
    resource: z.string().min(1).transform((r) => RESOURCE_ALIASES[r] ?? r),
    name: z.string().min(1).optional(),
  }),
  z.object({
    role: z.literal('run'),
    action: z.string().min(1),
  }),
]);

const RbacBindingSpecSchema = z.object({
  name: z.string().min(1),
  subjects: z.array(RbacSubjectSchema).default([]),
  roleBindings: z.array(RbacRoleBindingSchema).default([]),
});

const PromptSpecSchema = z.object({
  name: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/),
  content: z.string().min(1).max(50000).optional(),
  projectId: z.string().optional(),
  project: z.string().optional(),
  priority: z.number().int().min(1).max(10).optional(),
  link: z.string().optional(),
  linkTarget: z.string().optional(),
});

const ServerAttachmentSpecSchema = z.object({
  server: z.string().min(1),
  project: z.string().min(1),
});

const ProjectSpecSchema = z.object({
  name: z.string().min(1),
  description: z.string().default(''),
  prompt: z.string().max(10000).default(''),
  proxyModel: z.string().optional(),
  gated: z.boolean().optional(),
  llmProvider: z.string().optional(),
  llmModel: z.string().optional(),
  servers: z.array(z.string()).default([]),
});

const ApplyConfigSchema = z.object({
  secrets: z.array(SecretSpecSchema).default([]),
  servers: z.array(ServerSpecSchema).default([]),
  users: z.array(UserSpecSchema).default([]),
  groups: z.array(GroupSpecSchema).default([]),
  projects: z.array(ProjectSpecSchema).default([]),
  templates: z.array(TemplateSpecSchema).default([]),
  serverattachments: z.array(ServerAttachmentSpecSchema).default([]),
  rbacBindings: z.array(RbacBindingSpecSchema).default([]),
  rbac: z.array(RbacBindingSpecSchema).default([]),
  prompts: z.array(PromptSpecSchema).default([]),
}).transform((data) => ({
  ...data,
  // Merge rbac into rbacBindings so both keys work
  rbacBindings: [...data.rbacBindings, ...data.rbac],
}));

export type ApplyConfig = z.infer<typeof ApplyConfigSchema>;

export interface ApplyCommandDeps {
  client: ApiClient;
  log: (...args: unknown[]) => void;
}

export function createApplyCommand(deps: ApplyCommandDeps): Command {
  const { client, log } = deps;

  return new Command('apply')
    .description('Apply declarative configuration from a YAML or JSON file')
    .argument('[file]', 'Path to config file (.yaml, .yml, or .json)')
    .option('-f, --file <file>', 'Path to config file (alternative to positional arg)')
    .option('--dry-run', 'Validate and show changes without applying')
    .action(async (fileArg: string | undefined, opts: { file?: string; dryRun?: boolean }) => {
      const file = fileArg ?? opts.file;
      if (!file) {
        throw new Error('File path required. Usage: mcpctl apply <file> or mcpctl apply -f <file>');
      }
      const config = loadConfigFile(file);

      if (opts.dryRun) {
        log('Dry run - would apply:');
        if (config.secrets.length > 0) log(`  ${config.secrets.length} secret(s)`);
        if (config.servers.length > 0) log(`  ${config.servers.length} server(s)`);
        if (config.users.length > 0) log(`  ${config.users.length} user(s)`);
        if (config.groups.length > 0) log(`  ${config.groups.length} group(s)`);
        if (config.projects.length > 0) log(`  ${config.projects.length} project(s)`);
        if (config.templates.length > 0) log(`  ${config.templates.length} template(s)`);
        if (config.serverattachments.length > 0) log(`  ${config.serverattachments.length} serverattachment(s)`);
        if (config.rbacBindings.length > 0) log(`  ${config.rbacBindings.length} rbacBinding(s)`);
        if (config.prompts.length > 0) log(`  ${config.prompts.length} prompt(s)`);
        return;
      }

      await applyConfig(client, config, log);
    });
}

function readStdin(): string {
  const chunks: Buffer[] = [];
  const buf = Buffer.alloc(4096);
  try {
    // eslint-disable-next-line no-constant-condition
    while (true) {
      const bytesRead = readSync(0, buf, 0, buf.length, null);
      if (bytesRead === 0) break;
      chunks.push(buf.subarray(0, bytesRead));
    }
  } catch {
    // EOF or closed pipe
  }
  return Buffer.concat(chunks).toString('utf-8');
}

/** Map singular kind → plural resource key used by ApplyConfigSchema */
const KIND_TO_RESOURCE: Record<string, string> = {
  server: 'servers',
  project: 'projects',
  secret: 'secrets',
  template: 'templates',
  user: 'users',
  group: 'groups',
  rbac: 'rbac',
  prompt: 'prompts',
  promptrequest: 'promptrequests',
  serverattachment: 'serverattachments',
};

/**
 * Convert multi-doc format (array of {kind, ...} items) into the grouped
 * format that ApplyConfigSchema expects.
 */
function multiDocToGrouped(docs: Array<Record<string, unknown>>): Record<string, unknown[]> {
  const grouped: Record<string, unknown[]> = {};
  for (const doc of docs) {
    const kind = doc.kind as string;
    const resource = KIND_TO_RESOURCE[kind] ?? kind;
    const { kind: _k, ...rest } = doc;
    if (!grouped[resource]) grouped[resource] = [];
    grouped[resource].push(rest);
  }
  return grouped;
}

function loadConfigFile(path: string): ApplyConfig {
  const raw = path === '-' ? readStdin() : readFileSync(path, 'utf-8');
  let parsed: unknown;

  const isJson = path === '-' ? raw.trimStart().startsWith('{') || raw.trimStart().startsWith('[') : path.endsWith('.json');
  if (isJson) {
    parsed = JSON.parse(raw);
  } else {
    // Try multi-document YAML first
    const docs: unknown[] = [];
    yaml.loadAll(raw, (doc) => docs.push(doc));
    const allDocs = docs.flatMap((d) => Array.isArray(d) ? d : [d]) as Array<Record<string, unknown>>;
    if (allDocs.length > 0 && allDocs[0] != null && 'kind' in allDocs[0]) {
      // Multi-doc or single doc with kind field
      parsed = multiDocToGrouped(allDocs);
    } else {
      parsed = docs[0]; // Fall back to single-doc grouped format
    }
  }

  // JSON: handle array of {kind, ...} docs
  if (Array.isArray(parsed)) {
    const arr = parsed as Array<Record<string, unknown>>;
    if (arr.length > 0 && arr[0] != null && 'kind' in arr[0]) {
      parsed = multiDocToGrouped(arr);
    }
  }

  return ApplyConfigSchema.parse(parsed);
}

async function applyConfig(client: ApiClient, config: ApplyConfig, log: (...args: unknown[]) => void): Promise<void> {
  // Apply order: secrets, servers, users, groups, projects, templates, rbacBindings

  // Cache for name→record lookups to avoid repeated API calls (rate limit protection)
  const nameCache = new Map<string, Map<string, { id: string; [key: string]: unknown }>>();

  async function cachedFindByName(resource: string, name: string): Promise<{ id: string; [key: string]: unknown } | null> {
    if (!nameCache.has(resource)) {
      try {
        const items = await client.get<Array<{ id: string; name: string }>>(`/api/v1/${resource}`);
        const map = new Map<string, { id: string; [key: string]: unknown }>();
        for (const item of items) {
          if (item.name) map.set(item.name, item);
        }
        nameCache.set(resource, map);
      } catch {
        nameCache.set(resource, new Map());
      }
    }
    return nameCache.get(resource)!.get(name) ?? null;
  }

  /** Invalidate a resource cache after a create/update so subsequent lookups see it */
  function invalidateCache(resource: string): void {
    nameCache.delete(resource);
  }

  /** Retry a function on 429 rate-limit errors with exponential backoff */
  async function withRetry<T>(fn: () => Promise<T>, maxRetries = 5): Promise<T> {
    for (let attempt = 0; ; attempt++) {
      try {
        return await fn();
      } catch (err) {
        const msg = err instanceof Error ? err.message : String(err);
        if (attempt < maxRetries && msg.includes('429')) {
          const delay = 2000 * Math.pow(2, attempt); // 2s, 4s, 8s, 16s, 32s
          process.stderr.write(`\r\x1b[33mRate limited, retrying in ${delay / 1000}s...\x1b[0m`);
          await new Promise((r) => setTimeout(r, delay));
          process.stderr.write('\r\x1b[K'); // clear the line
          continue;
        }
        throw err;
      }
    }
  }

  // Apply secrets
  for (const secret of config.secrets) {
    try {
      const existing = await cachedFindByName('secrets', secret.name);
      if (existing) {
        await withRetry(() => client.put(`/api/v1/secrets/${existing.id}`, { data: secret.data }));
        log(`Updated secret: ${secret.name}`);
      } else {
        await withRetry(() => client.post('/api/v1/secrets', secret));
        invalidateCache('secrets');
        log(`Created secret: ${secret.name}`);
      }
    } catch (err) {
      log(`Error applying secret '${secret.name}': ${err instanceof Error ? err.message : err}`);
    }
  }

  // Apply servers
  for (const server of config.servers) {
    try {
      const existing = await cachedFindByName('servers', server.name);
      if (existing) {
        await withRetry(() => client.put(`/api/v1/servers/${existing.id}`, server));
        log(`Updated server: ${server.name}`);
      } else {
        await withRetry(() => client.post('/api/v1/servers', server));
        invalidateCache('servers');
        log(`Created server: ${server.name}`);
      }
    } catch (err) {
      log(`Error applying server '${server.name}': ${err instanceof Error ? err.message : err}`);
    }
  }

  // Apply users (matched by email)
  for (const user of config.users) {
    try {
      // Users use email, not name — use uncached findByField
      const existing = await findByField(client, 'users', 'email', user.email);
      if (existing) {
        await withRetry(() => client.put(`/api/v1/users/${(existing as { id: string }).id}`, user));
        log(`Updated user: ${user.email}`);
      } else {
        await withRetry(() => client.post('/api/v1/users', user));
        log(`Created user: ${user.email}`);
      }
    } catch (err) {
      log(`Error applying user '${user.email}': ${err instanceof Error ? err.message : err}`);
    }
  }

  // Apply groups
  for (const group of config.groups) {
    try {
      const existing = await cachedFindByName('groups', group.name);
      if (existing) {
        await withRetry(() => client.put(`/api/v1/groups/${existing.id}`, group));
        log(`Updated group: ${group.name}`);
      } else {
        await withRetry(() => client.post('/api/v1/groups', group));
        invalidateCache('groups');
        log(`Created group: ${group.name}`);
      }
    } catch (err) {
      log(`Error applying group '${group.name}': ${err instanceof Error ? err.message : err}`);
    }
  }

  // Apply projects (send full spec including servers)
  for (const project of config.projects) {
    try {
      const existing = await cachedFindByName('projects', project.name);
      if (existing) {
        await withRetry(() => client.put(`/api/v1/projects/${existing.id}`, project));
        log(`Updated project: ${project.name}`);
      } else {
        await withRetry(() => client.post('/api/v1/projects', project));
        invalidateCache('projects');
        log(`Created project: ${project.name}`);
      }
    } catch (err) {
      log(`Error applying project '${project.name}': ${err instanceof Error ? err.message : err}`);
    }
  }

  // Apply templates
  for (const template of config.templates) {
    try {
      const existing = await cachedFindByName('templates', template.name);
      if (existing) {
        await withRetry(() => client.put(`/api/v1/templates/${existing.id}`, template));
        log(`Updated template: ${template.name}`);
      } else {
        await withRetry(() => client.post('/api/v1/templates', template));
        invalidateCache('templates');
        log(`Created template: ${template.name}`);
      }
    } catch (err) {
      log(`Error applying template '${template.name}': ${err instanceof Error ? err.message : err}`);
    }
  }

  // Apply server attachments (after projects and servers exist)
  for (const sa of config.serverattachments) {
    try {
      const project = await cachedFindByName('projects', sa.project);
      if (!project) {
        log(`Error applying serverattachment: project '${sa.project}' not found`);
        continue;
      }
      await withRetry(() => client.post(`/api/v1/projects/${project.id}/servers`, { server: sa.server }));
      log(`Attached server '${sa.server}' to project '${sa.project}'`);
    } catch (err) {
      const msg = err instanceof Error ? err.message : String(err);
      // Ignore "already attached" conflicts silently
      if (msg.includes('409') || msg.includes('already')) {
        log(`Server '${sa.server}' already attached to project '${sa.project}'`);
      } else {
        log(`Error applying serverattachment '${sa.project}/${sa.server}': ${msg}`);
      }
    }
  }

  // Apply RBAC bindings
  for (const rbacBinding of config.rbacBindings) {
    try {
      const existing = await cachedFindByName('rbac', rbacBinding.name);
      if (existing) {
        await withRetry(() => client.put(`/api/v1/rbac/${existing.id}`, rbacBinding));
        log(`Updated rbacBinding: ${rbacBinding.name}`);
      } else {
        await withRetry(() => client.post('/api/v1/rbac', rbacBinding));
        invalidateCache('rbac');
        log(`Created rbacBinding: ${rbacBinding.name}`);
      }
    } catch (err) {
      log(`Error applying rbacBinding '${rbacBinding.name}': ${err instanceof Error ? err.message : err}`);
    }
  }

  // Apply prompts — project-scoped: same name in different projects are distinct resources.
  // Cache project-scoped prompt lookups separately from global cache.
  const promptProjectIds = new Map<string, string>();
  const projectPromptCache = new Map<string, Map<string, { id: string; [key: string]: unknown }>>();

  async function findPromptInProject(name: string, projectId: string | undefined): Promise<{ id: string; [key: string]: unknown } | null> {
    // Global prompts (no project) — use standard cache
    if (!projectId) {
      return cachedFindByName('prompts', name);
    }
    // Project-scoped: query prompts filtered by projectId
    if (!projectPromptCache.has(projectId)) {
      try {
        const items = await client.get<Array<{ id: string; name: string; projectId?: string }>>(`/api/v1/prompts?projectId=${projectId}`);
        const map = new Map<string, { id: string; [key: string]: unknown }>();
        for (const item of items) {
          if (item.name) map.set(item.name, item);
        }
        projectPromptCache.set(projectId, map);
      } catch {
        projectPromptCache.set(projectId, new Map());
      }
    }
    return projectPromptCache.get(projectId)!.get(name) ?? null;
  }

  for (const prompt of config.prompts) {
    try {
      // Resolve project name → projectId if needed
      let projectId = prompt.projectId;
      if (!projectId && prompt.project) {
        if (promptProjectIds.has(prompt.project)) {
          projectId = promptProjectIds.get(prompt.project)!;
        } else {
          const proj = await cachedFindByName('projects', prompt.project);
          if (!proj) {
            log(`Error applying prompt '${prompt.name}': project '${prompt.project}' not found`);
            continue;
          }
          projectId = proj.id;
          promptProjectIds.set(prompt.project, projectId);
        }
      }

      // Normalize: accept both `link` and `linkTarget`, prefer `link`
      const linkTarget = prompt.link ?? prompt.linkTarget;

      // Linked prompts use placeholder content if none provided
      const content = prompt.content ?? (linkTarget ? `Linked prompt — content fetched from ${linkTarget}` : '');
      if (!content) {
        log(`Error applying prompt '${prompt.name}': content is required (or provide link)`);
        continue;
      }

      // Build API body (strip the `project` name field, use projectId)
      const body: Record<string, unknown> = { name: prompt.name, content };
      if (projectId) body.projectId = projectId;
      if (prompt.priority !== undefined) body.priority = prompt.priority;
      if (linkTarget) body.linkTarget = linkTarget;

      const existing = await findPromptInProject(prompt.name, projectId);
      if (existing) {
        const updateData: Record<string, unknown> = { content };
        if (projectId) updateData.projectId = projectId;
        if (prompt.priority !== undefined) updateData.priority = prompt.priority;
        if (linkTarget) updateData.linkTarget = linkTarget;
        await withRetry(() => client.put(`/api/v1/prompts/${existing.id}`, updateData));
        log(`Updated prompt: ${prompt.name}`);
      } else {
        await withRetry(() => client.post('/api/v1/prompts', body));
        projectPromptCache.delete(projectId ?? '');
        log(`Created prompt: ${prompt.name}`);
      }
    } catch (err) {
      log(`Error applying prompt '${prompt.name}': ${err instanceof Error ? err.message : err}`);
    }
  }
}

async function findByField<T extends string>(client: ApiClient, resource: string, field: T, value: string): Promise<unknown | null> {
  try {
    const items = await client.get<Array<Record<string, unknown>>>(`/api/v1/${resource}`);
    return items.find((item) => item[field] === value) ?? null;
  } catch {
    return null;
  }
}

// Export for testing
export { loadConfigFile, applyConfig };
