import { readdirSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { PrismaClient } from '@prisma/client';
import yaml from 'js-yaml';
import { seedTemplates } from '@mcpctl/db';
import type { SeedTemplate } from '@mcpctl/db';
import { loadConfigFromEnv } from './config/index.js';
import { createServer } from './server.js';
import { setupGracefulShutdown } from './utils/index.js';
import {
  McpServerRepository,
  SecretRepository,
  McpInstanceRepository,
  ProjectRepository,
  AuditLogRepository,
  TemplateRepository,
  RbacDefinitionRepository,
  UserRepository,
  GroupRepository,
  AuditEventRepository,
  McpTokenRepository,
} from './repositories/index.js';
import { SecretBackendRepository } from './repositories/secret-backend.repository.js';
import { SecretBackendService } from './services/secret-backend.service.js';
import { SecretMigrateService } from './services/secret-migrate.service.js';
import { bootstrapSecretBackends } from './bootstrap/secret-backends.js';
import { backfillSecretKeyNames } from './bootstrap/secret-key-names.js';
import { registerSecretBackendRoutes } from './routes/secret-backends.js';
import { registerSecretMigrateRoutes } from './routes/secret-migrate.js';
import { SecretBackendRotator } from './services/secret-backend-rotator.service.js';
import { SecretBackendRotatorLoop } from './services/secret-backend-rotator-loop.js';
import { registerSecretBackendRotateRoutes } from './routes/secret-backend-rotate.js';
import { LlmRepository } from './repositories/llm.repository.js';
import { LlmService } from './services/llm.service.js';
import { InferenceTaskRepository } from './repositories/inference-task.repository.js';
import { InferenceTaskService } from './services/inference-task.service.js';
import { AgentRepository } from './repositories/agent.repository.js';
import { ChatRepository } from './repositories/chat.repository.js';
import { AgentService } from './services/agent.service.js';
import { ChatService } from './services/chat.service.js';
import { ChatToolDispatcherImpl } from './services/chat-tool-dispatcher.js';
import { LlmAdapterRegistry } from './services/llm/dispatcher.js';
import { registerLlmRoutes } from './routes/llms.js';
import { registerLlmInferRoutes } from './routes/llm-infer.js';
import { registerVirtualLlmRoutes } from './routes/virtual-llms.js';
import { registerInferenceTaskRoutes } from './routes/inference-tasks.js';
import { VirtualLlmService } from './services/virtual-llm.service.js';
import { registerAgentRoutes } from './routes/agents.js';
import { registerAgentChatRoutes } from './routes/agent-chat.js';
import { registerProjectChatRoutes } from './routes/project-chat.js';
import { PromptRepository } from './repositories/prompt.repository.js';
import { PromptRequestRepository } from './repositories/prompt-request.repository.js';
import { PersonalityRepository } from './repositories/personality.repository.js';
import { PersonalityService } from './services/personality.service.js';
import { registerPersonalityRoutes } from './routes/personalities.js';
import { registerWebUi } from './routes/web-ui.js';
import { bootstrapSystemProject } from './bootstrap/system-project.js';
import { SET_OWN_PASSWORD_OPERATION } from './bootstrap/self-password-permission.js';
import { registerLogRoutes } from './routes/logs.js';
import { errorLogBuffer } from './services/error-log-buffer.js';
import { bootstrapSystemSkills } from './bootstrap/system-skills.js';
import {
  McpServerService,
  SecretService,
  InstanceService,
  ProjectService,
  AuditLogService,
  DockerContainerManager,
  KubernetesOrchestrator,
  MetricsCollector,
  HealthAggregator,
  BackupService,
  RestoreService,
  AuthService,
  McpProxyService,
  TemplateService,
  HealthProbeRunner,
  RbacDefinitionService,
  RbacService,
  UserService,
  GroupService,
  AuditEventService,
  McpTokenService,
} from './services/index.js';
import type { RbacAction } from './services/index.js';
import type { UpdateRbacDefinitionInput } from './validation/rbac-definition.schema.js';
import { createAuthMiddleware } from './middleware/auth.js';
import {
  registerMcpServerRoutes,
  registerSecretRoutes,
  registerInstanceRoutes,
  registerProjectRoutes,
  registerAuditLogRoutes,
  registerHealthMonitoringRoutes,
  registerBackupRoutes,
  registerAuthRoutes,
  registerMcpProxyRoutes,
  registerTemplateRoutes,
  registerRbacRoutes,
  registerUserRoutes,
  registerGroupRoutes,
  registerAuditEventRoutes,
  registerMcpTokenRoutes,
} from './routes/index.js';
import { registerPromptRoutes } from './routes/prompts.js';
import { registerRevisionRoutes } from './routes/revisions.js';
import { registerProposalRoutes } from './routes/proposals.js';
import { registerSkillRoutes } from './routes/skills.js';
import { registerGitBackupRoutes } from './routes/git-backup.js';
import { PromptService } from './services/prompt.service.js';
import { ResourceRevisionRepository } from './repositories/resource-revision.repository.js';
import { ResourceProposalRepository } from './repositories/resource-proposal.repository.js';
import { ResourceRevisionService } from './services/resource-revision.service.js';
import { ResourceProposalService } from './services/resource-proposal.service.js';
import { SkillRepository } from './repositories/skill.repository.js';
import { SkillService } from './services/skill.service.js';
import { GitBackupService } from './services/backup/git-backup.service.js';
import type { BackupKind } from './services/backup/yaml-serializer.js';
import { ResourceRuleRegistry } from './validation/resource-rules.js';
import { systemPromptVarsRule } from './validation/rules/system-prompt-vars.js';

type PermissionCheck =
  | { kind: 'resource'; resource: string; action: RbacAction; resourceName?: string }
  | { kind: 'operation'; operation: string }
  | { kind: 'skip' };

/**
 * Map an HTTP method + URL to a permission check.
 * Returns 'skip' for URLs that should not be RBAC-checked.
 */
function mapUrlToPermission(method: string, url: string): PermissionCheck {
  const match = url.match(/^\/api\/v1\/([a-z-]+)/);
  if (!match) return { kind: 'skip' };

  const segment = match[1] as string;

  // Recent mcpd error logs — read-only operator view, gated by `logs`.
  if (url.startsWith('/api/v1/logs')) {
    return { kind: 'operation', operation: 'logs' };
  }

  // Self-service password change — gated by the `set-own-password` operation
  // (a default, admin-revocable permission), NOT the broad edit:users that an
  // admin reset of another user needs. Must precede the generic users mapping.
  if (url.startsWith('/api/v1/users/me/password')) {
    return { kind: 'operation', operation: SET_OWN_PASSWORD_OPERATION };
  }

  // Operations (non-resource endpoints)
  if (segment === 'backup') return { kind: 'operation', operation: 'backup' };
  if (segment === 'restore') return { kind: 'operation', operation: 'restore' };
  if (segment === 'audit-logs' && method === 'DELETE') return { kind: 'operation', operation: 'audit-purge' };
  // /api/v1/secrets/migrate is a bulk cross-backend operation — treat as op, not a plain secret write.
  if (url.startsWith('/api/v1/secrets/migrate')) return { kind: 'operation', operation: 'migrate-secrets' };
  // /api/v1/secretbackends/:id/rotate — manual rotation trigger. Operation so
  // only explicitly-granted callers can force it (the loop itself bypasses
  // RBAC by calling the rotator in-process).
  if (/^\/api\/v1\/secretbackends\/[^/?]+\/rotate/.test(url)) {
    return { kind: 'operation', operation: 'rotate-secretbackend' };
  }

  // /api/v1/llms/:name/infer → `run:llms:<name>` (not the default create:llms).
  const inferMatch = url.match(/^\/api\/v1\/llms\/([^/?]+)\/infer/);
  if (inferMatch?.[1]) {
    return { kind: 'resource', resource: 'llms', action: 'run', resourceName: inferMatch[1] };
  }

  // /api/v1/agents/:name/chat or /threads* → `run:agents:<name>`.
  // Driving a turn or managing its history is a "run" on the agent — listing
  // and CRUD continue to fall through to the default mapping below.
  const agentRunMatch = url.match(/^\/api\/v1\/agents\/([^/?]+)\/(chat|threads)/);
  if (agentRunMatch?.[1]) {
    return { kind: 'resource', resource: 'agents', action: 'run', resourceName: agentRunMatch[1] };
  }

  // /api/v1/threads/:id/messages → `view:agents` (we don't carry the agent
  // name in the URL; the service-level owner check enforces fine-grained
  // access on top).
  if (url.startsWith('/api/v1/threads/')) {
    return { kind: 'resource', resource: 'agents', action: 'view' };
  }

  const resourceMap: Record<string, string | undefined> = {
    'servers': 'servers',
    'instances': 'instances',
    'secrets': 'secrets',
    'secretbackends': 'secretbackends',
    'projects': 'projects',
    'templates': 'templates',
    'users': 'users',
    'groups': 'groups',
    'rbac': 'rbac',
    'audit-logs': 'rbac',
    'mcp': 'servers',
    'prompts': 'prompts',
    'promptrequests': 'promptrequests',
    // PR-2: revisions/proposals piggyback on the prompts permission for now.
    // Anyone with view:prompts can read history; anyone with edit:prompts can
    // approve/reject proposals. PR-7 may split these out if RBAC granularity
    // becomes useful (e.g., a "reviewer" role).
    'revisions': 'prompts',
    'proposals': 'prompts',
    // PR-3: skills follow prompts for RBAC. A "skills" RBAC slot can be
    // split out later if the operator wants to scope skill writes more
    // tightly than prompt writes — for now, a senior reviewer who can
    // edit prompts can edit skills.
    'skills': 'prompts',
    'mcptokens': 'mcptokens',
    'llms': 'llms',
    // v5: durable inference task queue. Same default action mapping as
    // any other resource (GET=view, POST=create, DELETE=delete). The
    // route layer enforces owner-scoping on top — a caller without
    // resource-wide grant only sees their own tasks.
    'inference-tasks': 'tasks',
    'agents': 'agents',
    // Personalities inherit the agent's RBAC: managing a personality
    // requires view/edit/create/delete on the `agents` resource.
    'personalities': 'agents',
  };

  const resource = resourceMap[segment];
  if (resource === undefined) return { kind: 'skip' };

  // Special case: /api/v1/promptrequests/:id/approve → needs both delete+promptrequests and create+prompts
  // We check delete on promptrequests (the harder permission); create on prompts is checked in the service layer
  const approveMatch = url.match(/^\/api\/v1\/promptrequests\/([^/?]+)\/approve/);
  if (approveMatch?.[1]) {
    return { kind: 'resource', resource: 'promptrequests', action: 'delete', resourceName: approveMatch[1] };
  }

  // Special case: /api/v1/mcptokens/:id/revoke → treated as 'delete' on the token.
  const revokeMatch = url.match(/^\/api\/v1\/mcptokens\/([^/?]+)\/revoke/);
  if (revokeMatch?.[1]) {
    return { kind: 'resource', resource: 'mcptokens', action: 'delete', resourceName: revokeMatch[1] };
  }

  // Special case: /api/v1/projects/:name/(chat|threads) → run:projects:<name>.
  // Driving a project chat turn or managing its threads is a "run" on the
  // project; per-thread ownership + admin-delete are enforced in the service.
  const projectRunMatch = url.match(/^\/api\/v1\/projects\/([^/?]+)\/(chat|threads)/);
  if (projectRunMatch?.[1]) {
    return { kind: 'resource', resource: 'projects', action: 'run', resourceName: projectRunMatch[1] };
  }

  // Special case: /api/v1/projects/:name/prompts/visible → view prompts
  const visiblePromptsMatch = url.match(/^\/api\/v1\/projects\/([^/?]+)\/prompts\/visible/);
  if (visiblePromptsMatch?.[1]) {
    return { kind: 'resource', resource: 'prompts', action: 'view' };
  }

  // Special case: /api/v1/projects/:name/promptrequests → create promptrequests
  const projectPromptrequestsMatch = url.match(/^\/api\/v1\/projects\/([^/?]+)\/promptrequests/);
  if (projectPromptrequestsMatch?.[1] && method === 'POST') {
    return { kind: 'resource', resource: 'promptrequests', action: 'create' };
  }

  // Special case: /api/v1/projects/:id/instructions → view projects
  const instructionsMatch = url.match(/^\/api\/v1\/projects\/([^/?]+)\/instructions/);
  if (instructionsMatch?.[1]) {
    return { kind: 'resource', resource: 'projects', action: 'view', resourceName: instructionsMatch[1] };
  }

  // Special case: /api/v1/projects/:id/mcp-config → requires 'expose' permission
  const mcpConfigMatch = url.match(/^\/api\/v1\/projects\/([^/?]+)\/mcp-config/);
  if (mcpConfigMatch?.[1]) {
    return { kind: 'resource', resource: 'projects', action: 'expose', resourceName: mcpConfigMatch[1] };
  }

  // Special case: /api/v1/projects/:id/servers — attach/detach requires 'edit'
  const projectServersMatch = url.match(/^\/api\/v1\/projects\/([^/?]+)\/servers/);
  if (projectServersMatch?.[1] && method !== 'GET') {
    return { kind: 'resource', resource: 'projects', action: 'edit', resourceName: projectServersMatch[1] };
  }

  // Map HTTP method to action
  let action: RbacAction;
  switch (method) {
    case 'GET':
    case 'HEAD':
      action = 'view';
      break;
    case 'POST':
      action = 'create';
      break;
    case 'DELETE':
      action = 'delete';
      break;
    default: // PUT, PATCH
      action = 'edit';
      break;
  }

  // Extract resource name/ID from URL (3rd segment: /api/v1/servers/:nameOrId)
  const nameMatch = url.match(/^\/api\/v1\/[a-z-]+\/([^/?]+)/);
  const resourceName = nameMatch?.[1];

  const check: PermissionCheck = { kind: 'resource', resource, action };
  if (resourceName !== undefined) (check as { resourceName: string }).resourceName = resourceName;
  return check;
}

/** Operations an admin-equivalent user should always be able to run. */
const ADMIN_OPS = [
  'impersonate', 'logs', 'backup', 'restore', 'audit-purge',
  'migrate-secrets', 'rotate-secretbackend',
] as const;

/**
 * Keep admin-equivalent RBAC definitions in sync with the current set of
 * required operations. Handles two cases:
 *
 *   1. Legacy migration — any binding with `role: admin` is split into
 *      `{role: edit, resource}` + `{role: run, resource}`, plus every op in
 *      `ADMIN_OPS` is added (for `admin:*`).
 *   2. Ongoing back-fill — a row that ALREADY has `edit:*` AND `run:*` is
 *      treated as admin-equivalent: any op in `ADMIN_OPS` that isn't bound
 *      yet is appended. Runs every startup so new releases that introduce a
 *      new operation don't silently 403 existing admin users.
 *
 * Idempotent — writes only when something actually changed.
 */
async function migrateAdminRole(rbacRepo: InstanceType<typeof RbacDefinitionRepository>): Promise<void> {
  const definitions = await rbacRepo.findAll();
  for (const def of definitions) {
    const bindings = def.roleBindings as Array<Record<string, unknown>>;
    const hasAdminRole = bindings.some((b) => b['role'] === 'admin');
    const hasEditWildcard = bindings.some((b) => b['role'] === 'edit' && b['resource'] === '*');
    const hasRunWildcard = bindings.some((b) => b['role'] === 'run' && b['resource'] === '*');
    const isAlreadyAdminEquivalent = hasEditWildcard && hasRunWildcard;

    if (!hasAdminRole && !isAlreadyAdminEquivalent) continue;

    // Start from existing bindings, splitting any legacy `admin` roles.
    const newBindings: Array<Record<string, string>> = [];
    for (const b of bindings) {
      if (b['role'] === 'admin') {
        const resource = b['resource'] as string;
        newBindings.push({ role: 'edit', resource });
        newBindings.push({ role: 'run', resource });
      } else {
        newBindings.push(b as Record<string, string>);
      }
    }

    // Back-fill any missing ops. `admin:*` qualifies, and so does the
    // already-migrated {edit:*, run:*} pair — both are wildcard-admin.
    const hasWildcardAdmin = bindings.some((b) => b['role'] === 'admin' && b['resource'] === '*');
    const shouldAddOps = hasWildcardAdmin || isAlreadyAdminEquivalent;
    let opsAdded = 0;
    if (shouldAddOps) {
      for (const op of ADMIN_OPS) {
        if (!newBindings.some((b) => b['action'] === op)) {
          newBindings.push({ role: 'run', action: op });
          opsAdded++;
        }
      }
    }

    // Only write if something actually changed — avoids noisy no-op startups.
    const changed = hasAdminRole || opsAdded > 0;
    if (!changed) continue;

    await rbacRepo.update(def.id, { roleBindings: newBindings as UpdateRbacDefinitionInput['roleBindings'] });
    // eslint-disable-next-line no-console
    if (hasAdminRole) {
      console.log(`mcpd: migrated RBAC '${def.name}' from admin → granular roles (+${String(opsAdded)} ops)`);
    } else {
      console.log(`mcpd: back-filled ${String(opsAdded)} op binding(s) on admin-equivalent RBAC '${def.name}'`);
    }
  }
}

async function main(): Promise<void> {
  const config = loadConfigFromEnv();

  // Database
  const prisma = new PrismaClient({
    datasources: { db: { url: config.databaseUrl } },
  });
  await prisma.$connect();

  // Seed templates from YAML files
  const templatesDir = process.env.TEMPLATES_DIR ?? 'templates';
  const templateFiles = (() => {
    try {
      return readdirSync(templatesDir).filter((f) => f.endsWith('.yaml') || f.endsWith('.yml'));
    } catch {
      return [];
    }
  })();
  const templates: SeedTemplate[] = templateFiles.map((f) => {
    const content = readFileSync(join(templatesDir, f), 'utf-8');
    const parsed = yaml.load(content) as SeedTemplate;
    return {
      ...parsed,
      transport: parsed.transport ?? 'STDIO',
      version: parsed.version ?? '1.0.0',
      description: parsed.description ?? '',
      ...(parsed.healthCheck ? { healthCheck: parsed.healthCheck } : {}),
    };
  });
  await seedTemplates(prisma, templates);

  // Bootstrap system project and prompts
  await bootstrapSystemProject(prisma);
  // PR-4: bootstrap system-owned global skills (e.g. propose-learnings).
  await bootstrapSystemSkills(prisma);

  // Repositories
  const serverRepo = new McpServerRepository(prisma);
  const secretRepo = new SecretRepository(prisma);
  const secretBackendRepo = new SecretBackendRepository(prisma);
  const llmRepo = new LlmRepository(prisma);
  const agentRepo = new AgentRepository(prisma);
  const chatRepo = new ChatRepository(prisma);
  const instanceRepo = new McpInstanceRepository(prisma);
  const projectRepo = new ProjectRepository(prisma);
  const auditLogRepo = new AuditLogRepository(prisma);
  const auditEventRepo = new AuditEventRepository(prisma);
  const templateRepo = new TemplateRepository(prisma);
  const rbacDefinitionRepo = new RbacDefinitionRepository(prisma);
  const userRepo = new UserRepository(prisma);
  const groupRepo = new GroupRepository(prisma);
  const mcpTokenRepo = new McpTokenRepository(prisma);

  // SecretBackend bootstrap: ensure a `plaintext` default row exists and any
  // pre-existing `Secret` rows are pointed at it. Idempotent per run.
  await bootstrapSecretBackends(prisma);

  // CUID detection for RBAC name resolution
  const CUID_RE = /^c[^\s-]{8,}$/i;
  const nameResolvers: Record<string, { findById(id: string): Promise<{ name: string } | null> }> = {
    servers: serverRepo,
    secrets: secretRepo,
    secretbackends: secretBackendRepo,
    projects: projectRepo,
    groups: groupRepo,
    mcptokens: mcpTokenRepo,
    llms: llmRepo,
    agents: agentRepo,
    // Resolve user CUID → email so name-scoped `edit:users:<email>` bindings
    // match on PUT /api/v1/users/:id/password (admin reset).
    users: { findById: async (id: string) => {
      const u = await userRepo.findById(id);
      return u ? { name: u.email } : null;
    } },
  };

  // Migrate legacy 'admin' role → granular roles
  await migrateAdminRole(rbacDefinitionRepo);

  // Orchestrator — select backend via MCPD_ORCHESTRATOR env var
  const orchestrator = process.env['MCPD_ORCHESTRATOR'] === 'kubernetes'
    ? new KubernetesOrchestrator()
    : new DockerContainerManager();

  // Services
  const serverService = new McpServerService(serverRepo);
  // SecretBackend service — needs a lazy bridge to the yet-to-be-constructed
  // SecretService because the OpenBao driver's auth token lives in a plaintext
  // Secret. The bridge defers the resolve until after `secretService` is
  // assigned, breaking the circular dependency at construction time.
  const secretResolverBridge = {
    resolve: async (name: string, key: string): Promise<string> => secretService.resolve(name, key),
  };
  const secretBackendService = new SecretBackendService(secretBackendRepo, {
    plaintext: {
      listAllPlaintext: async () => {
        const rows = await prisma.secret.findMany({
          where: { backend: { type: 'plaintext' } },
          select: { name: true, data: true },
        });
        return rows.map((r) => ({ name: r.name, data: r.data as Record<string, string> }));
      },
    },
    secretRefResolver: secretResolverBridge,
  });
  const secretService = new SecretService(secretRepo, secretBackendService);
  const secretMigrateService = new SecretMigrateService(secretRepo, secretBackendService);
  const secretBackendRotator = new SecretBackendRotator({
    backends: secretBackendService,
    secrets: secretService,
  });
  const secretBackendRotatorLoop = new SecretBackendRotatorLoop({
    backends: secretBackendService,
    rotator: secretBackendRotator,
  });
  const llmAdapters = new LlmAdapterRegistry();
  // LlmService takes the adapter registry so create()/update() can run an
  // auth probe at registration time. Keeps registration honest: misconfigured
  // tokens or wrong URLs surface as a 422 at create, not as a "fetch failed"
  // 502 at first chat. Logger forwards inconclusive probes (network down,
  // proxy doesn't expose /v1/models) to mcpd's structured log so operators
  // can still see them without blocking registration.
  const llmService = new LlmService(llmRepo, secretService, {
    adapters: llmAdapters,
    log: { warn: (msg) => app.log.warn(msg) },
  });
  // VirtualLlmService is constructed lower down (after AgentService) so
  // it can wire the agent-cascade callbacks introduced in v3 Stage 2.
  // AgentService + ChatService get fully wired below once projectService and
  // mcpProxyService are constructed (ChatService needs them via the
  // ChatToolDispatcher bridge).
  const instanceService = new InstanceService(instanceRepo, serverRepo, orchestrator, secretService);
  serverService.setInstanceService(instanceService);
  const projectService = new ProjectService(projectRepo, serverRepo);
  const auditLogService = new AuditLogService(auditLogRepo);
  const auditEventService = new AuditEventService(auditEventRepo);
  const metricsCollector = new MetricsCollector();
  const healthAggregator = new HealthAggregator(metricsCollector, orchestrator);
  const authService = new AuthService(prisma);
  const templateService = new TemplateService(templateRepo);
  const mcpProxyService = new McpProxyService(instanceRepo, serverRepo, orchestrator);
  const rbacDefinitionService = new RbacDefinitionService(rbacDefinitionRepo);
  const rbacService = new RbacService(rbacDefinitionRepo, prisma);
  const mcpTokenService = new McpTokenService(mcpTokenRepo, projectRepo, rbacDefinitionRepo, rbacService);
  const userService = new UserService(userRepo);
  const groupService = new GroupService(groupRepo, userRepo);
  const promptRepo = new PromptRepository(prisma);
  const promptRequestRepo = new PromptRequestRepository(prisma);
  const promptRuleRegistry = new ResourceRuleRegistry();
  promptRuleRegistry.register(systemPromptVarsRule);
  const promptService = new PromptService(promptRepo, promptRequestRepo, projectRepo, promptRuleRegistry, agentRepo);
  // PR-2: shared revision/proposal infra. Promp service registers its
  // 'prompt' approval handler with the proposal service via setProposalService;
  // PR-3 wires the same for skills.
  const resourceRevisionRepo = new ResourceRevisionRepository(prisma);
  const resourceRevisionService = new ResourceRevisionService(resourceRevisionRepo);
  const resourceProposalRepo = new ResourceProposalRepository(prisma);
  const resourceProposalService = new ResourceProposalService(resourceProposalRepo, prisma);
  promptService.setRevisionService(resourceRevisionService);
  promptService.setProposalService(resourceProposalService);
  // PR-3: Skill resource. Reuses the same revision/proposal infra.
  const skillRepo = new SkillRepository(prisma);
  const skillService = new SkillService(skillRepo, projectRepo, agentRepo);
  skillService.setRevisionService(resourceRevisionService);
  skillService.setProposalService(resourceProposalService);
  const personalityRepo = new PersonalityRepository(prisma);
  const personalityService = new PersonalityService(personalityRepo, agentRepo, promptRepo);
  const agentService = new AgentService(agentRepo, llmService, projectService, personalityRepo);
  // v5: durable inference task queue. VirtualLlmService persists infer
  // tasks here; the result-route updates them; an in-process emitter
  // wakes blocked HTTP handlers when results land.
  const inferenceTaskRepo = new InferenceTaskRepository(prisma);
  const inferenceTaskService = new InferenceTaskService(inferenceTaskRepo);
  // Virtual-provider state machine (kind=virtual rows for both Llms and
  // Agents). v3 wires AgentService for heartbeat/disconnect/GC cascade.
  // v5 wires inferenceTaskService — enqueueInferTask now persists rows,
  // worker disconnect reverts claimed rows, worker bind drains pending.
  // The 60-s GC ticker is started below after `app.listen`.
  const virtualLlmService = new VirtualLlmService(llmRepo, agentService, inferenceTaskService);
  // ChatService needs the proxy + project repo via the ChatToolDispatcher
  // bridge. The dispatcher's logger references `app.log`, which is not
  // constructed until further down — `chatService` itself is built right
  // before its routes register, just like `gitBackup`.
  const backupService = new BackupService(serverRepo, projectRepo, secretRepo, userRepo, groupRepo, rbacDefinitionRepo, promptRepo, templateRepo);
  const restoreService = new RestoreService(serverRepo, projectRepo, secretRepo, secretService, userRepo, groupRepo, rbacDefinitionRepo, promptRepo, templateRepo);

  // Shared auth dependencies. Both the global auth hook and the per-route
  // preHandler on /api/v1/mcp/proxy must know how to resolve both session
  // bearers AND mcpctl_pat_ bearers, or mcplocal→mcpd proxy calls with a
  // McpToken will 401 at the route layer even though the global hook accepts them.
  const authDeps = {
    findSession: (token: string) => authService.findSession(token),
    findMcpToken: async (tokenHash: string) => {
      const row = await mcpTokenRepo.findByHash(tokenHash);
      if (row === null) return null;
      return {
        tokenId: row.id,
        tokenName: row.name,
        tokenSha: row.tokenHash,
        projectId: row.projectId,
        projectName: row.project.name,
        ownerId: row.ownerId,
        expiresAt: row.expiresAt,
        revokedAt: row.revokedAt,
      };
    },
  };
  const authMiddleware = createAuthMiddleware(authDeps);

  // Server
  const app = await createServer(config, {
    health: {
      checkDb: async () => {
        try {
          await prisma.$queryRaw`SELECT 1`;
          return true;
        } catch {
          return false;
        }
      },
    },
  });

  // ── Global auth hook ──
  // Runs on all /api/v1/* routes EXCEPT auth endpoints and health checks.
  // Tests that use createServer() directly are NOT affected — this hook
  // is only registered here in main.ts.
  app.addHook('preHandler', async (request, reply) => {
    const url = request.url;
    // Skip auth for health, auth, and root
    if (url.startsWith('/api/v1/auth/') || url === '/healthz' || url === '/health') return;
    // Introspection authenticates via the McpToken bearer itself — route handles its own auth.
    if (url.startsWith('/api/v1/mcptokens/introspect')) return;
    if (!url.startsWith('/api/v1/')) return;

    // Run auth middleware
    await authMiddleware(request, reply);
  });

  // ── Global RBAC hook ──
  // Runs after the auth hook. Maps URL to resource+action and checks permissions.
  app.addHook('preHandler', async (request, reply) => {
    if (reply.sent) return; // Auth hook already rejected
    const url = request.url;
    if (url.startsWith('/api/v1/auth/') || url === '/healthz' || url === '/health') return;
    if (!url.startsWith('/api/v1/')) return;
    if (request.userId === undefined) return; // Auth hook will handle 401

    const check = mapUrlToPermission(request.method, url);
    if (check.kind === 'skip') return;

    // Extract service account identity from header (sent by mcplocal)
    const saHeader = request.headers['x-service-account'];
    const serviceAccountName = typeof saHeader === 'string' ? saHeader : undefined;

    // McpToken principal (set by authMiddleware when the bearer was mcpctl_pat_…)
    const mcpTokenSha = request.mcpToken?.tokenSha;

    // Second layer of project-scope enforcement: a McpToken principal can only
    // hit resources inside its bound project.
    if (request.mcpToken !== undefined) {
      const projectMatch = url.match(/^\/api\/v1\/projects\/([^/?]+)/);
      if (projectMatch?.[1]) {
        let targetProjectName = projectMatch[1];
        if (CUID_RE.test(targetProjectName)) {
          const entity = await projectRepo.findById(targetProjectName);
          if (entity) targetProjectName = entity.name;
        }
        if (targetProjectName !== request.mcpToken.projectName) {
          return reply.code(403).send({ error: 'Token is not valid for this project' });
        }
      }
    }

    let allowed: boolean;
    if (check.kind === 'operation') {
      allowed = await rbacService.canRunOperation(request.userId, check.operation, serviceAccountName, mcpTokenSha);
    } else {
      // Resolve CUID → human name for name-scoped RBAC bindings
      if (check.resourceName !== undefined && CUID_RE.test(check.resourceName)) {
        const resolver = nameResolvers[check.resource];
        if (resolver) {
          const entity = await resolver.findById(check.resourceName);
          if (entity) check.resourceName = entity.name;
        }
      }
      allowed = await rbacService.canAccess(request.userId, check.action, check.resource, check.resourceName, serviceAccountName, mcpTokenSha);
      // Compute scope for list filtering (used by preSerialization hook)
      if (allowed && check.resourceName === undefined) {
        request.rbacScope = await rbacService.getAllowedScope(request.userId, check.action, check.resource, serviceAccountName, mcpTokenSha);
      }
    }
    if (!allowed) {
      return reply.code(403).send({ error: 'Forbidden' });
    }
  });

  // Routes
  registerMcpServerRoutes(app, serverService, instanceService);
  registerTemplateRoutes(app, templateService);
  registerSecretRoutes(app, secretService);
  registerSecretBackendRoutes(app, secretBackendService);
  registerSecretBackendRotateRoutes(app, secretBackendRotator);
  registerSecretMigrateRoutes(app, secretMigrateService);
  registerLlmRoutes(app, llmService);
  registerAgentRoutes(app, agentService);
  registerPersonalityRoutes(app, personalityService);
  // ChatService needs an `app.log`-aware tool dispatcher.
  const chatToolDispatcher = new ChatToolDispatcherImpl({
    proxy: mcpProxyService,
    projects: projectRepo,
    logger: { warn: (obj, msg) => app.log.warn(obj, msg) },
  });
  const chatService = new ChatService(
    agentService,
    llmService,
    llmAdapters,
    chatRepo,
    promptRepo,
    chatToolDispatcher,
    personalityRepo,
    virtualLlmService,
    projectRepo,
    rbacService,
    secretService,
  );
  registerAgentChatRoutes(app, chatService);
  registerProjectChatRoutes(app, chatService, rbacService);
  registerLlmInferRoutes(app, {
    llmService,
    adapters: llmAdapters,
    virtualLlms: virtualLlmService,
    onInferenceEvent: (event) => {
      app.log.info({
        event: 'llm_inference_call',
        llm: event.llmName,
        model: event.model,
        type: event.type,
        userId: event.userId,
        tokenSha: event.tokenSha,
        streaming: event.streaming,
        durationMs: event.durationMs,
        status: event.status,
      });
    },
  });
  registerVirtualLlmRoutes(app, virtualLlmService, agentService);
  registerInferenceTaskRoutes(app, {
    tasks: inferenceTaskService,
    llms: llmService,
    virtualLlms: virtualLlmService,
  });
  registerInstanceRoutes(app, instanceService);
  registerProjectRoutes(app, projectService);
  registerAuditLogRoutes(app, auditLogService);
  registerAuditEventRoutes(app, auditEventService);
  registerHealthMonitoringRoutes(app, { healthAggregator, metricsCollector });
  registerBackupRoutes(app, { backupService, restoreService });
  registerAuthRoutes(app, { authService, userService, groupService, rbacDefinitionService, rbacService });
  registerMcpProxyRoutes(app, {
    mcpProxyService,
    auditLogService,
    authDeps,
  });
  registerRbacRoutes(app, rbacDefinitionService);
  registerUserRoutes(app, { userService, rbacDefinitionService, prisma });
  registerLogRoutes(app, errorLogBuffer);
  registerGroupRoutes(app, groupService);
  registerMcpTokenRoutes(app, { tokenService: mcpTokenService, projectRepo });
  registerPromptRoutes(app, promptService, projectRepo, agentRepo);
  registerRevisionRoutes(app, { revisionService: resourceRevisionService, promptService });
  registerProposalRoutes(app, { proposalService: resourceProposalService, projectRepo, agentRepo });
  registerSkillRoutes(app, skillService, projectRepo, agentRepo);

  // ── Git-based backup ──
  const gitBackup = new GitBackupService(prisma);

  // Hook: enqueue backup after successful mutations (checks enabled at call time)
  const kindFromSegment: Record<string, BackupKind | undefined> = {
    servers: 'server', secrets: 'secret', projects: 'project',
    templates: 'template', users: 'user', groups: 'group',
    rbac: 'rbac', prompts: 'prompt', skills: 'skill',
  };
  app.addHook('onSend', async (request, reply, payload) => {
    if (!gitBackup.enabled) return payload;
    if (reply.statusCode >= 400) return payload;
    const method = request.method;
    if (method === 'GET' || method === 'HEAD') return payload;

    const urlMatch = request.url.match(/^\/api\/v1\/([a-z-]+)(?:\/([^/?]+))?/);
    if (!urlMatch) return payload;
    const kind = kindFromSegment[urlMatch[1]!];
    if (!kind) return payload;

    let action: 'create' | 'update' | 'delete';
    if (method === 'DELETE') action = 'delete';
    else if (method === 'POST') action = 'create';
    else action = 'update';

    // Get resource name: from URL for update/delete, from response body for create
    const nameField = kind === 'user' ? 'email' : 'name';
    let resourceName = urlMatch[2];
    if (!resourceName && typeof payload === 'string') {
      try {
        const body = JSON.parse(payload);
        resourceName = body[nameField];
      } catch { /* ignore parse errors */ }
    }
    if (!resourceName) return payload;

    const userName = request.userId ?? 'system';
    gitBackup.enqueue(kind, resourceName, action, userName).catch((err) => {
      app.log.error({ err }, `Git backup enqueue failed for ${kind}/${resourceName}`);
    });
    return payload;
  });

  // Import/delete callbacks for git restore
  const importResource = async (kind: BackupKind, _name: string, doc: Record<string, unknown>) => {
    const data = { ...doc };
    delete data.kind;
    switch (kind) {
      case 'server': await serverService.upsertByName(data); break;
      case 'secret': await secretService.upsertByName(data); break;
      case 'project': await projectService.upsertByName(data, 'system'); break;
      case 'user': await userService.upsertByEmail(data); break;
      case 'group': await groupService.upsertByName(data); break;
      case 'rbac': await rbacDefinitionService.upsertByName(data); break;
      case 'prompt': await promptService.upsertByName(data); break;
      case 'template': await templateService.upsertByName(data); break;
    }
  };
  const deleteResource = async (kind: BackupKind, name: string) => {
    switch (kind) {
      case 'server': await serverService.deleteByName(name); break;
      case 'secret': await secretService.deleteByName(name); break;
      case 'project': await projectService.deleteByName(name); break;
      case 'user': await userService.deleteByEmail(name); break;
      case 'group': await groupService.deleteByName(name); break;
      case 'rbac': await rbacDefinitionService.deleteByName(name); break;
      case 'prompt': await promptService.deleteByName(name); break;
      case 'template': await templateService.deleteByName(name); break;
    }
  };
  gitBackup.setCallbacks(importResource, deleteResource);

  // Init async — reads config from backup-ssh secret, don't block startup
  gitBackup.init().catch((err) => app.log.error({ err }, 'Git backup init failed'));

  registerGitBackupRoutes(app, gitBackup);

  // ── RBAC list filtering hook ──
  //
  // Two filters compose here, in order:
  //
  //   1. RBAC name-scope (existing): when a caller has only name-scoped
  //      grants (no resource-wide), only items whose name is in their
  //      grants set pass through. wildcard=true skips this step.
  //
  //   2. v7 visibility filter (new): private rows are hidden from
  //      callers who aren't the owner and don't have a name-scoped
  //      grant. Skipped for `*`-resource admins (isAdmin=true) so
  //      org-wide audit/list operations still work. Importantly, this
  //      runs even when wildcard=true — that's how a regular `view:llms`
  //      grant stops broadcasting private virtuals across the org while
  //      still letting `*`-grant admins see everything.
  //
  // Items without a `visibility` field (today: every resource other
  // than Llm and Agent) pass through this step unchanged.
  app.addHook('preSerialization', async (request, _reply, payload) => {
    if (!request.rbacScope) return payload;
    if (!Array.isArray(payload)) return payload;
    let items = payload as Array<Record<string, unknown>>;
    // Step 1: RBAC name-scope.
    if (!request.rbacScope.wildcard) {
      items = items.filter((item) => {
        const name = item['name'];
        return typeof name === 'string' && request.rbacScope!.names.has(name);
      });
    }
    // Step 2: visibility (only meaningful when the resource carries it).
    if (!request.rbacScope.isAdmin) {
      const userId = request.userId;
      items = items.filter((item) => {
        const visibility = item['visibility'];
        if (visibility !== 'private') return true;
        const ownerId = item['ownerId'];
        if (typeof ownerId === 'string' && ownerId === userId) return true;
        const name = item['name'];
        if (typeof name === 'string' && request.rbacScope!.names.has(name)) return true;
        return false;
      });
    }
    return items;
  });

  // Web UI: served from /ui (static SPA bundle). Falls through to API
  // routes when the prefix doesn't match. Skipped silently if the bundle
  // isn't installed (dev tree without `pnpm --filter @mcpctl/web build`).
  await registerWebUi(app);

  // Start
  await app.listen({ port: config.port, host: config.host });
  app.log.info(`mcpd listening on ${config.host}:${config.port}`);

  // Periodic reconciliation loop — the operator's heartbeat.
  // Detects crashed/missing containers, cleans up ERROR instances,
  // and starts replacements to match desired replica counts.
  const RECONCILE_INTERVAL_MS = 30_000; // 30s
  const reconcileTimer = setInterval(async () => {
    try {
      const { reconciled, errors } = await instanceService.reconcileAll();
      if (reconciled > 0) {
        app.log.info(`[reconcile] ${reconciled} server(s) reconciled`);
      }
      for (const err of errors) {
        app.log.error(`[reconcile] ${err}`);
      }
    } catch (err) {
      app.log.error({ err }, 'Reconciliation loop failed');
    }
  }, RECONCILE_INTERVAL_MS);

  // Virtual-LLM GC sweep — flips heartbeat-stale rows to inactive (90 s
  // cutoff) and deletes inactives past the 4 h retention window. Runs
  // every 60 s; cheap (two indexed queries) when there are no virtuals.
  const VIRTUAL_LLM_GC_INTERVAL_MS = 60_000;
  const virtualLlmGcTimer = setInterval(async () => {
    try {
      const { markedInactive, deleted } = await virtualLlmService.gcSweep();
      if (markedInactive > 0 || deleted > 0) {
        app.log.info(`[virtual-llm gc] markedInactive=${String(markedInactive)} deleted=${String(deleted)}`);
      }
    } catch (err) {
      app.log.error({ err }, 'Virtual LLM GC sweep failed');
    }
  }, VIRTUAL_LLM_GC_INTERVAL_MS);

  // v5: InferenceTask GC sweep — pending tasks aged out of the 1 h
  // queue timeout get flipped to error (so any caller still polling
  // sees a clean failure instead of an indefinite "pending"); terminal
  // tasks past 7 d retention get deleted to keep the table from
  // growing unboundedly. Runs every 5 min — slower than the v-llm
  // sweep because the queue's TTL granularity is coarser. Both index-
  // backed queries return empty fast when nothing's expired.
  const INFERENCE_TASK_GC_INTERVAL_MS = 5 * 60_000;
  const inferenceTaskGcTimer = setInterval(async () => {
    try {
      const { erroredOut, deleted } = await inferenceTaskService.gcSweep({
        pendingTimeoutMs: 60 * 60_000, // 1 h
        terminalRetentionMs: 7 * 24 * 60 * 60_000, // 7 d
      });
      if (erroredOut > 0 || deleted > 0) {
        app.log.info(`[inference-task gc] erroredOut=${String(erroredOut)} deleted=${String(deleted)}`);
      }
    } catch (err) {
      app.log.error({ err }, 'Inference task GC sweep failed');
    }
  }, INFERENCE_TASK_GC_INTERVAL_MS);

  // Health probe runner — periodic MCP probes (like k8s livenessProbe).
  // Without explicit healthCheck.tool, probes send tools/list through
  // McpProxyService so they traverse the exact production call path.
  const healthProbeRunner = new HealthProbeRunner(
    instanceRepo,
    serverRepo,
    orchestrator,
    { info: (msg) => app.log.info(msg), error: (obj, msg) => app.log.error(obj, msg) },
    mcpProxyService,
  );
  healthProbeRunner.start(15_000);

  // SecretBackend token rotator — wakes up for wizard-provisioned openbao
  // backends only, noop for the rest. Errors inside the loop are logged +
  // surfaced in `describe secretbackend`, never kill the process.
  secretBackendRotatorLoop.start().catch((err: unknown) => {
    app.log.error({ err }, 'secret-backend rotator loop failed to start');
  });

  // One-shot: populate Secret.keyNames for any backend-stored secrets that
  // pre-date the column. Idempotent + non-fatal — the lazy backfill in
  // SecretService.getById covers the per-row retry case.
  backfillSecretKeyNames(
    prisma,
    secretService,
    { info: (m) => app.log.info(m), warn: (m) => app.log.warn(m) },
  ).catch((err: unknown) => {
    app.log.error({ err }, 'secret keyNames backfill failed');
  });

  // Graceful shutdown
  setupGracefulShutdown(app, {
    disconnectDb: async () => {
      clearInterval(reconcileTimer);
      clearInterval(virtualLlmGcTimer);
      clearInterval(inferenceTaskGcTimer);
      healthProbeRunner.stop();
      secretBackendRotatorLoop.stop();
      gitBackup.stop();
      await prisma.$disconnect();
    },
  });
}

main().catch((err) => {
  console.error('Failed to start mcpd:', err);
  process.exit(1);
});
