import 'reflect-metadata';

import { createGuardManager, type GuardManager } from '@frontmcp/guard';
import { type EventStore } from '@frontmcp/protocol';
import { getEnvFlag, getMachineId, getRuntimeContext, isEdgeRuntime } from '@frontmcp/utils';

import AgentRegistry from '../agent/agent.registry';
import CallAgentFlow from '../agent/flows/call-agent.flow';
import AppRegistry from '../app/app.registry';
import { AuthUiRegistry } from '../auth/auth-ui';
import { AuthRegistry } from '../auth/auth.registry';
import { type ChannelNotificationService } from '../channel/channel-notification.service';
import { registerChannelCapabilities } from '../channel/channel-scope.helper';
import type ChannelRegistry from '../channel/channel.registry';
import { type ChannelEventBus } from '../channel/sources/app-event.source';
// NOTE: @frontmcp/auth is imported via require() at runtime in initAuthoritiesFromConfig()
// to avoid circular dependency issues with esbuild's __esm lazy initialization.
// Type references use inline import('...') syntax to avoid creating bundler dependencies.
import {
  FrontMcpLogger,
  FrontMcpServer,
  ProviderScope,
  ScopeEntry,
  type EntryOwnerRef,
  type FlowInputOf,
  type FlowName,
  type FlowOutputOf,
  type FlowType,
  type FrontMcpAuth,
  type ScopeRecord,
  type Token,
  type Type,
} from '../common';
import { type ChannelType } from '../common/interfaces/channel.interface';
import { type JobType } from '../common/interfaces/job.interface';
import { type WorkflowType } from '../common/interfaces/workflow.interface';
import { type ChannelsConfigOptions } from '../common/metadata/channel.metadata';
import { resolveDefaultSqlitePath, type SqliteOptionsInput } from '../common/types/options/sqlite';
import CompleteFlow from '../completion/flows/complete.flow';
import { FrontMcpContextProvider, FrontMcpContextStorage } from '../context';
import { createElicitationStore, type ElicitationStore } from '../elicitation';
import { ElicitationRequestFlow, ElicitationResultFlow } from '../elicitation/flows';
import { SendElicitationResultTool } from '../elicitation/send-elicitation-result.tool';
import { AuthConfigurationError, FlowExitedWithoutOutputError } from '../errors';
import FlowRegistry from '../flows/flow.registry';
import { HaManager } from '../ha';
import { HealthService } from '../health';
import HookRegistry from '../hooks/hook.registry';
import { normalizeHooksFromCls } from '../hooks/hooks.utils';
import { type JobExecutionManager } from '../job/execution/job-execution.manager';
import { registerJobCapabilities, type JobsConfig } from '../job/job-scope.helper';
import type JobRegistry from '../job/job.registry';
import { type JobDefinitionStore } from '../job/store/job-definition.interface';
import { type JobStateStore } from '../job/store/job-state.interface';
import SetLevelFlow from '../logging/flows/set-level.flow';
import { NotificationService } from '../notification';
import PluginRegistry, { type PluginScopeInfo } from '../plugin/plugin.registry';
import PromptRegistry from '../prompt/prompt.registry';
import ProviderRegistry from '../provider/provider.registry';
import ResourceRegistry from '../resource/resource.registry';
import { registerCustomHttpRoutes } from '../server/custom-routes.helper';
import { SkillValidationError } from '../skill/errors/skill-validation.error';
import { createSkillToolGuardHook } from '../skill/hooks';
import { createSkillSessionStore, SkillSessionManager } from '../skill/session';
import { registerSkillCapabilities } from '../skill/skill-scope.helper';
import SkillRegistry from '../skill/skill.registry';
import {
  CliTaskRunner,
  createTaskStore,
  InProcessTaskRunner,
  TaskNotifier,
  TaskRegistry,
  TasksCancelFlow,
  TasksGetFlow,
  TasksListFlow,
  TasksResultFlow,
  type TaskStore,
} from '../task';
import { ToolInstance } from '../tool/tool.instance';
import ToolRegistry from '../tool/tool.registry';
import { normalizeTool } from '../tool/tool.utils';
import { hasUIConfig, StaticWidgetResourceTemplate, ToolUIRegistry } from '../tool/ui';
import { RedisTransportBus } from '../transport/bus';
import { createEventStore } from '../transport/event-stores';
import { TransportService } from '../transport/transport.registry';
import type WorkflowRegistry from '../workflow/workflow.registry';
import HttpRequestFlow from './flows/http.request.flow';
import { probeOptionalDependency } from './optional-dependency.util';

export class Scope extends ScopeEntry {
  readonly id: string;
  private readonly globalProviders: ProviderRegistry;
  readonly logger: FrontMcpLogger;

  private readonly scopeProviders: ProviderRegistry;
  private scopeAuth: AuthRegistry;
  private scopeFlows: FlowRegistry;
  private scopeApps: AppRegistry;
  private scopeHooks: HookRegistry;
  private scopeTools: ToolRegistry;
  private scopeResources: ResourceRegistry;
  private scopePrompts: PromptRegistry;
  private scopeAgents: AgentRegistry;
  private scopeSkills: SkillRegistry;
  private scopePlugins?: PluginRegistry;

  transportService: TransportService; // TODO: migrate transport service to transport.registry
  notificationService: NotificationService;
  declare haManager?: HaManager;
  private toolUIRegistry: ToolUIRegistry;
  private authUiRegistry?: AuthUiRegistry;
  readonly entryPath: string;
  readonly routeBase: string;
  readonly orchestrated: boolean = false;

  readonly server: FrontMcpServer;

  /** Lazy-initialized elicitation store for distributed elicitation support */
  private _elicitationStore?: ElicitationStore;
  /** Backend type for the elicitation store (used by the orphan-sqlite WARN guard). */
  private elicitationBackendType?: 'memory' | 'redis' | 'upstash' | 'sqlite' | 'auto';

  /** Task store for MCP 2025-11-25 background tasks (optional). */
  private _taskStore?: TaskStore;
  /** Backend type for the task store (used by the orphan-sqlite WARN guard). */
  private taskBackendType?: 'memory' | 'redis' | 'upstash' | 'sqlite' | 'auto';

  /** Per-process task registry (AbortControllers + capability projection). */
  private _taskRegistry?: TaskRegistry;

  /** Optional skill session manager for tool authorization enforcement */
  private _skillSession?: SkillSessionManager;

  /** EventStore for SSE resumability support (optional) */
  private _eventStore?: EventStore;

  /** Job/workflow registries and execution (optional) */
  private _scopeJobs?: JobRegistry;
  private _scopeWorkflows?: WorkflowRegistry;
  private _jobExecutionManager?: JobExecutionManager;
  private _jobStateStore?: JobStateStore;
  private _jobDefinitionStore?: JobDefinitionStore;

  /** Guard manager for rate limiting, concurrency, IP filtering (optional) */
  private _rateLimitManager?: GuardManager;

  /** Authorities engine for RBAC/ABAC/ReBAC enforcement (optional) */
  private _authoritiesEngine?: import('@frontmcp/auth').AuthoritiesEngine;
  private _authoritiesContextBuilder?: import('@frontmcp/auth').AuthoritiesContextBuilder;
  private _authoritiesScopeMapping?: import('@frontmcp/auth').AuthoritiesScopeMapping;

  /** Channel system (optional) */
  private _scopeChannels?: ChannelRegistry;
  private _channelNotificationService?: ChannelNotificationService;
  private _channelEventBus?: ChannelEventBus;
  private _channelTeardown?: () => Promise<void>;

  /** Health service for liveness and readiness probes (optional) */
  private _healthService?: HealthService;

  /** CLI mode flag — skips non-essential initialization for faster startup */
  private readonly cliMode: boolean;

  constructor(rec: ScopeRecord, globalProviders: ProviderRegistry) {
    super(rec, rec.provide);
    this.id = rec.metadata.id;
    this.logger = globalProviders.get(FrontMcpLogger).child('FrontMcp.MultiAppScope');
    this.globalProviders = globalProviders;
    this.server = this.globalProviders.get(FrontMcpServer);
    this.entryPath = rec.metadata.http?.entryPath ?? '';

    if (rec.kind === 'SPLIT_BY_APP') {
      this.routeBase = `/${rec.metadata.id}`;
    } else {
      this.routeBase = '';
    }

    // Check if CLI mode was requested (set by FrontMcpInstance.createForCli)
    this.cliMode = !!(rec.metadata as unknown as Record<string, unknown>)['__cliMode'];

    // Pass distributed config to ProviderRegistry for serverless/multi-instance support
    const distributedMode = rec.metadata.transport?.distributedMode;
    const providerCaching = rec.metadata.transport?.providerCaching;
    // Register `@FrontMcp({ providers })` into the scope's provider list so the
    // existing parent-chain lookup in ProviderRegistry makes them visible to
    // every `@App` tool/resource/prompt/skill (issue #403).
    //
    // Ordering matters: ProviderRegistry#buildMap uses `Map.set(provide, rec)`,
    // which is last-write-wins. User providers MUST come BEFORE the framework
    // defaults so that the defaults (Scope, ScopeEntry, FrontMcpLogger,
    // FrontMcpContextStorage, FrontMcpContextProvider) write last and cannot
    // be accidentally shadowed by a user provider that happens to declare the
    // same token.
    const userProviders = rec.metadata.providers ?? [];
    this.scopeProviders = new ProviderRegistry([...userProviders, ...this.defaultScopeProviders], globalProviders, {
      distributedMode,
      providerCaching,
    });
    this.ready = this.initialize();
  }

  protected async initialize(): Promise<void> {
    await this.scopeProviders.ready;

    const scopeRef: EntryOwnerRef = { kind: 'scope', id: this.id, ref: Scope };
    const scopeProviders = this.scopeProviders;
    const perf = getEnvFlag('FRONTMCP_PERF');
    const t0 = perf ? performance.now() : 0;
    const mark = perf
      ? (label: string) => this.logger.info(`[PERF] ${label}: ${(performance.now() - t0).toFixed(1)}ms`)
      : () => {};

    // ═══ BATCH 1: Independent registries (parallel) ═══
    // These only depend on scopeProviders — no cross-registry dependencies.
    this.scopeHooks = new HookRegistry(scopeProviders, []);
    this.scopeFlows = new FlowRegistry(scopeProviders, [HttpRequestFlow]);
    this.scopeAuth = new AuthRegistry(this, scopeProviders, [], scopeRef, this.metadata.auth);
    this.scopeApps = new AppRegistry(this.scopeProviders, this.metadata.apps, scopeRef);
    this.logger.info(`Initializing ${this.metadata.apps.length} app(s)...`);

    // HA Manager (conditional — only in distributed deployment mode)
    // Must be created before TransportService so the bus can be passed to it.
    const transportConfig = this.metadata.transport;
    const isDistributed = getRuntimeContext().deployment === 'distributed';
    if (isDistributed && this.metadata.redis && !this.cliMode) {
      try {
        const haRedis = this.metadata.redis;
        this.haManager = HaManager.create({
          redis: haRedis as never,
          nodeId: getMachineId(),
          logger: this.logger,
        });
        await this.haManager.start();
        this.logger.info('[HA] Manager started for distributed deployment');
      } catch (err) {
        this.logger.warn('[HA] Failed to start HA manager — running without HA', { error: err });
      }
    }

    // Transport bus (distributed mode only — requires HA Manager + Redis)
    let transportBus: InstanceType<typeof RedisTransportBus> | undefined;
    if (this.haManager && isDistributed && this.metadata.redis) {
      transportBus = new RedisTransportBus(this.metadata.redis as never, getMachineId(), {
        logger: this.logger,
      });
      this.logger.info('[HA] Transport bus created for distributed session routing');
    }

    // Resolve top-level sqlite once (issue #401 — review-driven path policy):
    // if the user set `sqlite: {}` without a path, the resolver fills in
    //   dev+node+non-CLI → <projectRoot>/dist/sessions.sqlite
    //   prod or CLI      → ~/.{info.name}/sessions.sqlite
    // The resolved config flows into transport persistence + tasks +
    // elicitation so every consumer sees the same path.
    const resolvedTopLevelSqlite = await this.resolveTopLevelSqlite();

    // TransportService — pass the bus for distributed mode.
    // Auto-thread top-level `sqlite` into transport persistence when the user
    // hasn't explicitly configured `transport.persistence` (issue #401).
    // Top-level `redis` is left alone here so existing Redis-only users keep
    // the same behavior — they opt into transport persistence explicitly.
    let effectivePersistence = transportConfig?.persistence;
    if (effectivePersistence === undefined && resolvedTopLevelSqlite) {
      effectivePersistence = { sqlite: resolvedTopLevelSqlite };
    }
    this.transportService = new TransportService(this, effectivePersistence, transportBus);

    // Orphan session scanner (distributed mode only — scans for dead-pod sessions)
    if (this.haManager && isDistributed) {
      const persistCfg = transportConfig?.persistence;
      const resolvedPrefix =
        persistCfg && typeof persistCfg === 'object' && persistCfg.redis?.keyPrefix
          ? persistCfg.redis.keyPrefix
          : 'mcp:transport:';
      this.haManager.startOrphanScanner({
        sessionKeyPrefix: resolvedPrefix,
        onOrphan: (sessionId, previousNodeId) => {
          this.logger.info(
            `[HA] Orphaned session ${sessionId.slice(0, 20)} from ${previousNodeId} — available for recreation`,
          );
        },
      });
    }

    // Serverless SSE warning
    if (getRuntimeContext().deployment === 'serverless') {
      const protocolCfg = transportConfig?.protocol;
      const hasSSE = typeof protocolCfg === 'string' ? protocolCfg !== 'stateless-api' : protocolCfg?.sse === true;
      if (hasSSE) {
        this.logger.warn(
          '[Deployment] SSE transport is not supported in serverless deployments. ' +
            'Consider using protocol: "stateless-api" for serverless targets.',
        );
      }
    }

    // EventStore (conditional, skipped in CLI mode)
    // In distributed mode, auto-enable Redis event store for cross-pod SSE resumability
    const effectiveEventStoreConfig =
      transportConfig?.eventStore ??
      (isDistributed && this.metadata.redis && !this.cliMode
        ? { enabled: true, provider: 'redis' as const, redis: this.metadata.redis }
        : undefined);
    if (effectiveEventStoreConfig?.enabled && !this.cliMode) {
      const { eventStore } = createEventStore(effectiveEventStoreConfig, this.logger);
      this._eventStore = eventStore;
      this.logger.info('EventStore initialized for SSE resumability', {
        provider: effectiveEventStoreConfig.provider ?? 'memory',
        autoEnabled: !transportConfig?.eventStore && isDistributed,
      });
    }

    // Elicitation store (conditional, skipped in CLI mode)
    const elicitationEnabled = this.metadata.elicitation?.enabled === true && !this.cliMode;
    const elicitationPromise = elicitationEnabled
      ? (async () => {
          const elicitationRedis = this.metadata.elicitation?.redis ?? this.metadata.redis;
          // Top-level `sqlite` is the fallback when no Redis source is configured (issue #401).
          const elicitationSqlite = elicitationRedis ? undefined : resolvedTopLevelSqlite;
          const { store: elicitStore, type: elicitType } = await createElicitationStore({
            redis: elicitationRedis,
            sqlite: elicitationSqlite,
            keyPrefix: elicitationRedis?.keyPrefix ?? 'mcp:elicit:',
            logger: this.logger,
            isEdgeRuntime: isEdgeRuntime(),
          });
          this._elicitationStore = elicitStore;
          this.elicitationBackendType = elicitType;
        })()
      : undefined;

    // Tasks store + registry (MCP 2025-11-25 background tasks).
    //
    // Mode selection:
    //  - Long-lived Node server: always allocate. Runner = InProcessTaskRunner.
    //  - CLI host: allocate only when a persistent backend is configured
    //    (tasks.sqlite or tasks.redis) — tasks MUST outlive a single CLI
    //    invocation. Runner = CliTaskRunner (spawns detached workers).
    //  - Task worker (re-invoked by CliTaskRunner): allocate store, use
    //    InProcessTaskRunner because we ARE the worker now.
    //  - Edge runtime: warn (or throw when tasks.strict: true).
    const tasksConfig = this.metadata.tasks;
    const tasksExplicitlyDisabled = tasksConfig?.enabled === false;
    const isTaskWorker = !!(this.metadata as unknown as Record<string, unknown>)['__taskWorkerMode'];
    const hasPersistentBackend = Boolean(
      tasksConfig?.sqlite || tasksConfig?.redis || this.metadata.redis || this.metadata.sqlite,
    );
    const tasksEnabledForCli = this.cliMode && hasPersistentBackend;
    const shouldInitTasks = !tasksExplicitlyDisabled && (!this.cliMode || tasksEnabledForCli || isTaskWorker);

    const tasksPromise = shouldInitTasks
      ? (async () => {
          const tasksRedis = tasksConfig?.redis ?? this.metadata.redis;
          // Mirror the elicitation precedence: an explicit `tasks.sqlite` is
          // always honored, but the top-level `metadata.sqlite` fallback only
          // applies when there's no redis source — never pass both backends to
          // createTaskStore (issue #401 review).
          const tasksSqlite = tasksConfig?.sqlite ?? (tasksRedis ? undefined : resolvedTopLevelSqlite);
          const onEdge = isEdgeRuntime();
          if (onEdge) {
            const msg =
              '[tasks] Background tasks are enabled on an edge/serverless runtime. ' +
              'The in-process runner cannot continue past the HTTP response on this platform — ' +
              'task bodies will not execute. Run on a long-lived Node process, or set `tasks.enabled: false`.';
            if (tasksConfig?.strict) {
              throw new Error(msg + ' (tasks.strict: true refuses startup)');
            }
            this.logger.warn(msg);
          }
          const { store: taskStore, type: taskType } = await createTaskStore({
            redis: tasksRedis,
            sqlite: tasksSqlite,
            keyPrefix: tasksConfig?.keyPrefix ?? 'mcp:task:',
            logger: this.logger,
            isEdgeRuntime: onEdge,
          });
          this._taskStore = taskStore;
          this.taskBackendType = taskType;
          this._taskRegistry = new TaskRegistry(
            {
              enabled: tasksConfig?.enabled,
              defaultTtlMs: tasksConfig?.defaultTtlMs,
              maxTtlMs: tasksConfig?.maxTtlMs,
              defaultPollIntervalMs: tasksConfig?.defaultPollIntervalMs,
              maxConcurrentPerSession: tasksConfig?.maxConcurrentPerSession,
              keyPrefix: tasksConfig?.keyPrefix,
              strict: tasksConfig?.strict,
              sqlite: tasksConfig?.sqlite,
              cliRunnerCommand: tasksConfig?.cliRunnerCommand,
            },
            this.logger,
          );
          // Runner is attached AFTER the NotificationService is ready — see
          // the install step below (it needs access to `this.notificationService`).
        })()
      : undefined;
    // Remember whether to install a CLI runner vs in-process, deferred until
    // after the NotificationService is initialized in batch3.
    const installTaskRunner = shouldInitTasks
      ? () => {
          if (!this._taskStore || !this._taskRegistry) return;
          // Runner selection: explicit `tasks.runner` wins; a task worker is
          // ALWAYS in-process regardless of config (it's the worker itself).
          const explicit = tasksConfig?.runner;
          const useCliRunner = !isTaskWorker && explicit === 'cli';
          if (
            useCliRunner &&
            !tasksConfig?.sqlite &&
            !tasksConfig?.redis &&
            !this.metadata.redis &&
            !this.metadata.sqlite
          ) {
            // The CLI runner spawns a detached worker that HAS to read task state
            // from a shared backend. Memory-backed storage can't be shared across
            // processes, so accepting this config silently would guarantee broken
            // tasks at runtime. Refuse at startup.
            //
            // Top-level `metadata.sqlite` is also acceptable here — the task
            // store auto-wires it (see `tasksSqlite` resolution above), so
            // the CLI runner gets the same shared backend.
            throw new Error(
              '[tasks] runner: "cli" requires a persistent backend (`tasks.sqlite`, ' +
                '`tasks.redis`, or a top-level `sqlite`) — the detached worker cannot share ' +
                'an in-memory store with this host.',
            );
          }
          const runner = useCliRunner
            ? new CliTaskRunner({
                store: this._taskStore,
                command: tasksConfig?.cliRunnerCommand,
                logger: this.logger,
              })
            : new InProcessTaskRunner({
                scope: this as unknown as ConstructorParameters<typeof InProcessTaskRunner>[0]['scope'],
                store: this._taskStore,
                registry: this._taskRegistry,
                notifier: new TaskNotifier(this.notificationService, this.logger),
                logger: this.logger,
              });
          this._taskRegistry.setRunner(runner);
          this.logger.info(`[tasks] runner installed: ${runner.kind}`);
        }
      : undefined;

    // Guard manager (rate limiting, concurrency, IP filter) — conditional, skipped in CLI mode
    const throttleConfig = this.metadata.throttle;
    const rateLimitPromise =
      throttleConfig?.enabled && !this.cliMode
        ? createGuardManager({
            config: throttleConfig,
            logger: this.logger,
          }).then((mgr) => {
            this._rateLimitManager = mgr;
          })
        : undefined;

    // Await batch 1: hooks, flows, auth, apps + optional elicitation + rate limit — all in parallel
    const batch1: Promise<void>[] = [
      this.scopeHooks.ready,
      this.scopeFlows.ready,
      this.scopeAuth.ready,
      this.scopeApps.ready,
    ];
    if (elicitationPromise) batch1.push(elicitationPromise);
    if (tasksPromise) batch1.push(tasksPromise);
    if (rateLimitPromise) batch1.push(rateLimitPromise);
    await Promise.all(batch1);
    this.logger.verbose('HookRegistry initialized');
    this.logger.verbose('FlowRegistry initialized');
    this.logger.verbose('TransportService initialized');
    this.logger.verbose('AuthRegistry initialized');
    mark('batch1:parallel (hooks+flows+auth+apps)');

    // Issue #401 — guard against silent no-op when top-level `sqlite` is set
    // but no subsystem actually consumed it. We use the backend `type` each
    // factory returned (not mere truthiness of the store handle), so a user
    // who sets both top-level `redis` and `sqlite` still gets the warning:
    // redis wins everywhere, the stores exist, but sqlite is effectively
    // unused.
    if (this.metadata.sqlite) {
      const transportUsedSqlite =
        this.transportService.isSessionStoreConfigured() && this.transportService.getBackendKind() === 'sqlite';
      const tasksUsedSqlite = this.taskBackendType === 'sqlite';
      const elicitationUsedSqlite = this.elicitationBackendType === 'sqlite';
      if (!transportUsedSqlite && !tasksUsedSqlite && !elicitationUsedSqlite) {
        this.logger.warn(
          '[FrontMcp] Top-level `sqlite` config was provided but no subsystem consumes it ' +
            '(transport persistence, tasks, and elicitation were all disabled or pre-configured with a different backend). ' +
            'Sessions will use in-memory storage. Remove the `sqlite` block or enable one of the subsystems.',
        );
      }
    }

    // ═══ BATCH 2: App-dependent registries (parallel) ═══
    // These call providers.getRegistries('AppRegistry') during initialize(),
    // so AppRegistry must be fully ready before they start.
    const serverPlugins = [...(this.metadata.plugins ?? [])];

    // Auto-load ObservabilityPlugin when observability config is present.
    // Uses lazy require to avoid bundling @frontmcp/observability in browser builds.
    const observabilityConfig = this.metadata.observability;
    if (observabilityConfig) {
      let ObservabilityPluginModule: any;
      try {
        ObservabilityPluginModule = require('@frontmcp/observability');
      } catch (err) {
        // Don't blindly report "not installed" — `require()` also throws when the
        // package resolves but fails to load (export-condition / transpile / peer
        // mismatch, e.g. tsx + yarn resolving the source entry). Re-probe with the
        // runtime resolver so the warning matches reality and is debuggable (#453).
        const probe = probeOptionalDependency('@frontmcp/observability', err, require.resolve);
        if (probe.status === 'not-installed') {
          this.logger.warn(
            'observability config is set but @frontmcp/observability is not installed. ' +
              'Install it with: npm install @frontmcp/observability',
          );
        } else {
          this.logger.warn(
            'observability config is set and @frontmcp/observability resolved but failed to load — ' +
              'telemetry is disabled. Reinstalling will not help: this is usually an export-condition or ' +
              'transpile mismatch (e.g. a dev runtime resolving the package source instead of its built ' +
              'entry) or a failing transitive peer dependency. Inspect the resolved path and error below.',
            { resolvedPath: probe.resolvedPath, error: probe.error },
          );
        }
      }
      if (ObservabilityPluginModule)
        try {
          const { ObservabilityPlugin } = ObservabilityPluginModule;
          const pluginOptions = observabilityConfig === true ? {} : observabilityConfig;
          serverPlugins.push(ObservabilityPlugin.init(pluginOptions));
          this.logger.verbose('observability: auto-loaded ObservabilityPlugin from config');

          // Wire StructuredLogTransport into the SDK logger pipeline so that
          // every logger.info() call flows through it with trace correlation.
          const loggingEnabled = pluginOptions.logging !== false;
          if (loggingEnabled) {
            try {
              const { StructuredLogTransport, createSinks } = require('@frontmcp/observability');
              const LoggerRegistryCls = require('../logger/logger.registry').default;
              let loggerRegistry: any;
              try {
                loggerRegistry = this.globalProviders.get(LoggerRegistryCls);
              } catch {
                /* not available */
              }
              if (
                loggerRegistry &&
                typeof loggerRegistry.addTransport === 'function' &&
                !loggerRegistry._hasStructuredTransport
              ) {
                const rawLogging = pluginOptions.logging;
                const loggingOpts: Record<string, unknown> =
                  rawLogging === true ? {} : typeof rawLogging === 'object' ? rawLogging : {};
                const sinks = createSinks(loggingOpts['sinks'] as any);
                const { FrontMcpContextStorage: CtxStorage } = require('../context/frontmcp-context-storage');
                let contextStorage: any;
                try {
                  contextStorage = this.globalProviders.get(CtxStorage);
                } catch {
                  /* not available */
                }
                const contextAccessor = contextStorage
                  ? () => {
                      const ctx = contextStorage.getStore();
                      if (!ctx) return undefined;
                      return {
                        requestId: ctx.requestId,
                        traceContext: ctx.traceContext,
                        sessionIdHash: require('@frontmcp/utils').sha256Hex(ctx.sessionId).slice(0, 12),
                        scopeId: ctx.scopeId,
                        flowName: ctx.flow?.name,
                        elapsed: ctx.elapsed(),
                      };
                    }
                  : undefined;

                const transport = new StructuredLogTransport(
                  sinks,
                  {
                    redactFields: loggingOpts['redactFields'] as string[] | undefined,
                    includeStacks: loggingOpts['includeStacks'] as boolean | undefined,
                    staticFields: loggingOpts['staticFields'] as Record<string, unknown> | undefined,
                  },
                  contextAccessor,
                );

                loggerRegistry.addTransport(transport);
                loggerRegistry._hasStructuredTransport = true;
                this.logger.verbose('observability: wired StructuredLogTransport into SDK logger');
              }
            } catch (e) {
              this.logger.warn('observability: failed to wire structured logging', {
                error: e instanceof Error ? e.message : 'Unknown error',
              });
            }
          }

          // Auto-configure tracing exporter if none is set
          if (pluginOptions.tracing !== false) {
            try {
              const { trace } = require('@opentelemetry/api');
              const currentProvider = trace.getTracerProvider();
              // ProxyTracerProvider is the default no-op when nothing is configured
              const isNoop = !currentProvider || currentProvider.constructor?.name === 'ProxyTracerProvider';
              if (isNoop) {
                const { isDevelopment: checkDev } = require('@frontmcp/utils');
                if (checkDev()) {
                  const { BasicTracerProvider, SimpleSpanProcessor } = require('@opentelemetry/sdk-trace-base');
                  let exporter: any;
                  try {
                    const { PrettySpanExporter } = require('@frontmcp/observability');
                    exporter = new PrettySpanExporter();
                  } catch {
                    const { ConsoleSpanExporter } = require('@opentelemetry/sdk-trace-base');
                    exporter = new ConsoleSpanExporter();
                  }
                  const devProvider = new BasicTracerProvider();
                  devProvider.addSpanProcessor(new SimpleSpanProcessor(exporter));
                  devProvider.register();
                  this.logger.info('observability: auto-configured tracing (development mode)');
                } else {
                  this.logger.warn(
                    'observability: tracing enabled but no TracerProvider configured. ' +
                      'Set OTEL_EXPORTER_OTLP_ENDPOINT or call setupOTel() before server start.',
                  );
                }
              }
            } catch {
              // @opentelemetry/sdk-trace-base not installed — tracing will be no-op
            }
          }
        } catch (e) {
          this.logger.warn('observability: plugin initialization failed', {
            error: e instanceof Error ? e.message : 'Unknown error',
          });
        }
    }

    if (serverPlugins.length > 0) {
      const serverPluginScopeInfo: PluginScopeInfo = {
        ownScope: this,
        parentScope: undefined,
        isStandaloneApp: false,
      };
      this.scopePlugins = new PluginRegistry(this.scopeProviders, serverPlugins, scopeRef, serverPluginScopeInfo);
    }

    // Server-level plugins must initialize BEFORE scope registries so their
    // tool/resource/prompt registries are in scopeProviders and discoverable
    // during adoption. Without this ordering, plugin tools are "orphaned".
    if (this.scopePlugins) {
      await this.scopePlugins.ready;
    }

    // Initialize authorities engine from metadata config (built-in, no plugin needed)
    this.initAuthoritiesFromConfig();

    this.scopeTools = new ToolRegistry(this.scopeProviders, [], scopeRef);
    this.scopeResources = new ResourceRegistry(this.scopeProviders, [], scopeRef);
    this.scopePrompts = new PromptRegistry(this.scopeProviders, [], scopeRef);
    this.scopeAgents = new AgentRegistry(this.scopeProviders, [], scopeRef);
    this.scopeSkills = new SkillRegistry(this.scopeProviders, this.metadata.skills ?? [], scopeRef);

    await Promise.all([
      this.scopeTools.ready,
      this.scopeResources.ready,
      this.scopePrompts.ready,
      this.scopeAgents.ready,
      this.scopeSkills.ready,
    ]);

    if (this.scopePlugins) {
      const pluginNames = this.scopePlugins.getPluginNames();
      this.logger.verbose(`PluginRegistry initialized (${pluginNames.length} plugin(s): [${pluginNames.join(', ')}])`);
    }
    const toolNames = this.scopeTools.getTools(true).map((t) => t.metadata.name);
    this.logger.verbose(`ToolRegistry initialized with initial ${toolNames.length} tool(s): [${toolNames.join(', ')}]`);
    this.logger.verbose(`ResourceRegistry initialized (${this.scopeResources.getResources().length} resource(s))`);
    this.logger.verbose(`PromptRegistry initialized (${this.scopePrompts.getPrompts().length} prompt(s))`);
    this.logger.verbose(`AgentRegistry initialized (${this.scopeAgents.getAgents().length} agent(s))`);
    this.logger.verbose(`SkillRegistry initialized (${this.scopeSkills.getSkills().length} skill(s))`);
    mark('batch2:parallel (tools+resources+prompts+agents+skills+plugins)');

    // Fail-fast: entries with 'authorities' metadata but no engine configured
    this.validateAuthoritiesConfig();

    // ═══ BATCH 3: Cross-registry finalization (sequential) ═══

    // Register sendElicitationResult system tool if elicitation is enabled
    if (elicitationEnabled) {
      this.registerSendElicitationResultTool(scopeRef);
    }

    // Create UI import resolver with CDN overrides if configured
    // In CLI mode, skip UI widget compilation entirely — not needed for tool calls
    let uiResolver: import('@frontmcp/uipack/resolver').ImportResolver | undefined;
    if (!this.cliMode) {
      const cdnOverrides = this.metadata.ui?.cdnOverrides;
      if (cdnOverrides && Object.keys(cdnOverrides).length > 0) {
        const { createResolverWithOverrides } = require('@frontmcp/uipack/resolver');
        uiResolver = createResolverWithOverrides(cdnOverrides);
        this.logger.verbose('Created UI resolver with CDN overrides', { overrides: Object.keys(cdnOverrides) });
      }
    }

    this.toolUIRegistry = new ToolUIRegistry(uiResolver);

    // Register UI resource templates if any tools have UI configs
    // Skipped in CLI mode — UI widgets are not needed for CLI tool execution
    if (!this.cliMode) {
      await this.compileUIWidgets();
    }

    // Build the custom auth-UI registry (#469) from this scope's resolved auth
    // options (`auth.ui` / `auth.extras`) — per-scope, so each `splitByApp` app
    // gets its own. Skipped in CLI mode (no HTTP auth pages). When no custom
    // slots/extras are declared the OAuth flows fall back to the built-in pages.
    if (!this.cliMode) {
      await this.buildAuthUiRegistry();
    }

    // Initialize skill session manager if skills are available
    if (this.scopeSkills.hasAny()) {
      const store = createSkillSessionStore({ type: 'memory' });

      this._skillSession = new SkillSessionManager(
        {
          defaultPolicyMode: 'permissive',
        },
        this.logger,
        store,
      );

      // Register skill tool authorization guard hook
      const GuardHookClass = createSkillToolGuardHook(this._skillSession, {
        logger: this.logger,
        trackToolCalls: true,
      });
      const guardHookInstance = new GuardHookClass();
      const hookRecords = normalizeHooksFromCls(guardHookInstance);
      if (hookRecords.length > 0) {
        await this.scopeHooks.registerHooks(true, ...hookRecords);
        this.logger.verbose('Skill tool authorization guard hook registered');
      }

      this.logger.verbose('Skill session manager initialized', {
        storeType: store.type,
      });
    }

    // Validate all skills after everything is initialized
    if (this.scopeSkills.hasAny()) {
      try {
        const report = await this.scopeSkills.validateAllTools();
        if (report.warningCount > 0) {
          this.logger.verbose('Skill validation completed with warnings', {
            totalSkills: report.totalSkills,
            warningCount: report.warningCount,
          });
        }
      } catch (error) {
        if (error instanceof SkillValidationError) {
          this.logger.error('Skill validation failed', {
            failedSkills: error.failedSkills.map((s) => ({
              name: s.skillName,
              missingTools: s.missingTools,
            })),
          });
          throw error;
        }
        throw error;
      }
    }

    // Register skill flows and resources if any skills are available
    await registerSkillCapabilities({
      skillRegistry: this.scopeSkills,
      flowRegistry: this.scopeFlows,
      resourceRegistry: this.scopeResources,
      providers: this.scopeProviders,
      skillsConfig: this.metadata.skillsConfig,
      logger: this.logger,
    });

    // Initialize jobs and workflows (issue #408).
    //
    // Auto-enable rule: if any `@App({ jobs: [...] })` (or `workflows: [...]`)
    // is declared, the jobs subsystem comes up implicitly with in-memory
    // stores so the management tools (`execute_job`, `list_jobs`, …) are
    // reachable from agents without requiring the user to flip a separate
    // `@FrontMcp({ jobs: { enabled: true } })` flag they were never told
    // about. Explicit `jobs.enabled === false` still wins (opt-out).
    const jobsConfig = this.metadata.jobs as JobsConfig | undefined;
    const metaRecord = this.metadata as unknown as Record<string, unknown>;
    const jobsList = (metaRecord['jobTypes'] as JobType[] | undefined) ?? [];
    const workflowsList = (metaRecord['workflowTypes'] as WorkflowType[] | undefined) ?? [];

    // Collect jobs/workflows from apps regardless of gate so we can detect
    // implicit enablement (and so the disabled-but-declared warning fires).
    const appJobs: JobType[] = [];
    const appWorkflows: WorkflowType[] = [];
    for (const app of this.scopeApps.getApps()) {
      const appMeta = app.metadata as unknown as Record<string, unknown>;
      if (Array.isArray(appMeta['jobs'])) appJobs.push(...(appMeta['jobs'] as JobType[]));
      if (Array.isArray(appMeta['workflows'])) appWorkflows.push(...(appMeta['workflows'] as WorkflowType[]));
    }

    const allJobs = [...jobsList, ...appJobs];
    const allWorkflows = [...workflowsList, ...appWorkflows];
    const hasJobDeclarations = allJobs.length > 0 || allWorkflows.length > 0;
    const explicitlyDisabled = jobsConfig?.enabled === false;
    const explicitlyEnabled = jobsConfig?.enabled === true;
    const autoEnabled = !explicitlyEnabled && !explicitlyDisabled && hasJobDeclarations;
    const effectivelyEnabled = explicitlyEnabled || autoEnabled;

    if (explicitlyDisabled && hasJobDeclarations) {
      this.logger.warn(
        `Jobs are explicitly disabled but ${allJobs.length} job(s) and ${allWorkflows.length} workflow(s) were declared on @App. ` +
          `Agents will NOT be able to invoke them — remove jobs.enabled: false or drop the @App({ jobs }) declarations.`,
      );
    }

    if (effectivelyEnabled) {
      if (autoEnabled) {
        this.logger.info(
          `Jobs auto-enabled: detected ${allJobs.length} job(s) and ${allWorkflows.length} workflow(s) in @App declarations. ` +
            `Defaulting to in-memory stores — configure storage via @FrontMcp({ jobs: { store: ... } }) for multi-replica HA.`,
        );
      }

      const effectiveConfig: JobsConfig = { ...(jobsConfig ?? {}), enabled: true };
      const notifyFn = async (data: Record<string, unknown>) => {
        if (this.notificationService) {
          this.logger.debug('Job notification', data);
        }
      };

      const result = await registerJobCapabilities({
        providers: this.scopeProviders,
        owner: scopeRef,
        jobsList: allJobs,
        workflowsList: allWorkflows,
        jobsConfig: effectiveConfig,
        logger: this.logger,
        notifyFn,
      });

      this._scopeJobs = result.jobRegistry as JobRegistry;
      this._scopeWorkflows = result.workflowRegistry as WorkflowRegistry;
      this._jobExecutionManager = result.executionManager;
      this._jobStateStore = result.stateStore;
      this._jobDefinitionStore = result.definitionStore;

      // Register job/workflow management tools
      for (const toolCls of result.managementTools) {
        try {
          const toolRecord = normalizeTool(toolCls);
          const toolInstance = new ToolInstance(toolRecord, this.scopeProviders, {
            kind: 'scope',
            id: '_jobs',
            ref: toolCls,
          });
          this.scopeTools.registerToolInstance(toolInstance);
        } catch (error) {
          this.logger.warn(
            `Failed to register job management tool: ${error instanceof Error ? error.message : String(error)}`,
          );
        }
      }

      this.logger.info(
        `Jobs system initialized: ${this._scopeJobs.getJobs().length} jobs, ${this._scopeWorkflows.getWorkflows().length} workflows`,
      );
    }

    // Initialize notification service after all registries are ready
    this.notificationService = new NotificationService(this);
    await this.notificationService.initialize();
    this.logger.verbose('NotificationService initialized');

    // Install the task runner now that the notification service is available.
    // The runner needs it to deliver `notifications/tasks/status` events.
    installTaskRunner?.();

    // Register logging, completion, agent, elicitation, and task flows.
    await this.scopeFlows.registryFlows([
      SetLevelFlow,
      CompleteFlow,
      CallAgentFlow,
      ElicitationRequestFlow,
      ElicitationResultFlow,
      TasksGetFlow,
      TasksResultFlow,
      TasksCancelFlow,
      TasksListFlow,
    ]);

    await this.auth.ready;
    await this.transportService.ready;

    // Register first-class custom HTTP routes (issue #465). Runs after auth is
    // ready so `auth: true` routes can dispatch the `session:verify` flow. The
    // reserved-path guard uses the resolved entryPath + routeBase, so it is
    // aware of split-by-app scope bases (e.g. /mcp/billing).
    registerCustomHttpRoutes({
      routes: this.metadata.http?.routes,
      server: this.server,
      // `session:verify` input uses a loose passthrough request shape; the
      // ServerRequest class instance satisfies it at runtime.
      verifySession: (request) =>
        this.runFlow('session:verify', { request: request as unknown as Record<string, unknown> }),
      entryPath: this.entryPath,
      routeBase: this.routeBase,
      logger: this.logger,
    });

    // Initialize channels if enabled (after notification service and all registries are ready)
    const channelsConfig = this.metadata.channels as ChannelsConfigOptions | undefined;
    if (channelsConfig?.enabled) {
      // Collect channel definitions from apps
      const appChannels: ChannelType[] = [];
      for (const app of this.scopeApps.getApps()) {
        const appMeta = app.metadata as unknown as Record<string, unknown>;
        if (Array.isArray(appMeta['channels'])) appChannels.push(...(appMeta['channels'] as ChannelType[]));
      }

      // TODO: Pass agentEmitterSubscribe/jobEmitterSubscribe when agent/job registries
      // expose completion event subscriptions. Currently agent-completion and job-completion
      // channel sources are not wired (the helper logs a warning and skips them).
      const channelResult = await registerChannelCapabilities({
        providers: this.scopeProviders,
        owner: scopeRef,
        channelsList: appChannels,
        channelsConfig,
        notificationService: this.notificationService,
        flowRegistry: this.scopeFlows,
        toolRegistry: this.scopeTools,
        logger: this.logger,
      });

      this._scopeChannels = channelResult.channelRegistry;
      this._channelNotificationService = channelResult.channelNotificationService;
      this._channelEventBus = channelResult.channelEventBus;
      this._channelTeardown = channelResult.teardown;
    }

    // Initialize health service (after all registries and stores are ready)
    if (this.metadata.health?.enabled !== false) {
      this._healthService = new HealthService(this.metadata.health ?? {}, this.metadata.info);
      this._healthService.autoDiscoverProbes(this);
      this.logger.verbose(`HealthService initialized (${this._healthService.getProbeCount()} probe(s))`);
    }

    mark('batch3:finalization');
    this.logger.info(`Scope ready — ${this.formatScopeSummary()}`);
  }

  /**
   * Resolve the top-level `sqlite` config, filling in a default `path`
   * when the user provided the block without one (issue #401). Also
   * ensures the parent directory exists so better-sqlite3 doesn't fail
   * to open. Returns `undefined` if the user didn't set the block at
   * all. The returned object is a SHALLOW COPY — never mutate the
   * caller's metadata.
   */
  private async resolveTopLevelSqlite(): Promise<(SqliteOptionsInput & { path: string }) | undefined> {
    const raw = this.metadata.sqlite;
    if (!raw) return undefined;
    let finalPath = raw.path;
    if (!finalPath || finalPath.length === 0) {
      finalPath = resolveDefaultSqlitePath({
        appName: this.metadata.info?.name,
        cliMode: this.cliMode,
      });
      this.logger.info('[FrontMcp] No `sqlite.path` set — using default', { path: finalPath });
    }
    try {
      const { dirname } = require('path') as typeof import('path');
      const { mkdir } = await import('@frontmcp/utils');
      await mkdir(dirname(finalPath), { recursive: true });
    } catch (err) {
      this.logger.warn('[FrontMcp] Could not ensure sqlite parent directory exists', {
        path: finalPath,
        error: (err as Error).message,
      });
    }
    return { ...raw, path: finalPath } as SqliteOptionsInput & { path: string };
  }

  /**
   * Pre-compile UI widgets for tools with UI configs.
   * Extracted from initialize() for readability.
   */
  private async compileUIWidgets(): Promise<void> {
    const toolsWithUI = this.scopeTools.getTools(true).filter((t) => hasUIConfig(t.metadata));
    if (toolsWithUI.length === 0) return;

    // Register static widget template for OpenAI discovery (ui://widget/{toolName}.html)
    this.scopeResources.registerDynamicResource(StaticWidgetResourceTemplate);
    this.logger.verbose(`Registered UI resource template for ${toolsWithUI.length} tool(s) with UI configs`);

    // Pre-compile static widgets for tools with servingMode: 'static'
    const staticModeTools = toolsWithUI.filter(
      (t) => t.metadata.ui && t.metadata.ui.servingMode === 'static' && t.metadata.ui.template,
    );

    if (staticModeTools.length > 0) {
      let staticCompiledCount = 0;
      await Promise.all(
        staticModeTools.map(async (tool) => {
          const uiConfig = tool.metadata.ui;
          if (!uiConfig?.template) {
            this.logger.warn(
              `Skipping static widget pre-compile for tool "${tool.metadata.name}" due to missing ui.template`,
            );
            return;
          }
          try {
            await this.toolUIRegistry.compileStaticWidgetAsync({
              toolName: tool.metadata.name,
              template: uiConfig.template,
              uiConfig,
            });
            staticCompiledCount++;
            this.logger.verbose(`Compiled static widget for tool: ${tool.metadata.name}`);
          } catch (error) {
            const errorMessage = error instanceof Error ? error.message : String(error);
            this.logger.error(`Failed to compile static widget for tool "${tool.metadata.name}": ${errorMessage}`);
          }
        }),
      );
      this.logger.info(
        `Pre-compiled ${staticCompiledCount}/${staticModeTools.length} static widget(s) for static mode tools`,
      );
    }

    // Pre-compile lean widget shells for inline mode tools
    const inlineTools = toolsWithUI.filter(
      (t) =>
        t.metadata.ui &&
        (t.metadata.ui.servingMode === 'inline' || !t.metadata.ui.servingMode) &&
        t.metadata.ui.template,
    );

    if (inlineTools.length > 0) {
      let inlineCompiledCount = 0;
      await Promise.all(
        inlineTools.map(async (tool) => {
          const uiConfig = tool.metadata.ui;
          if (!uiConfig) {
            this.logger.warn(
              `Skipping lean widget pre-compile for tool "${tool.metadata.name}" due to missing ui config`,
            );
            return;
          }
          try {
            await this.toolUIRegistry.compileLeanWidgetAsync({
              toolName: tool.metadata.name,
              uiConfig,
            });
            inlineCompiledCount++;
            this.logger.verbose(`Compiled lean widget shell for tool: ${tool.metadata.name}`);
          } catch (error) {
            const errorMessage = error instanceof Error ? error.message : String(error);
            this.logger.error(`Failed to compile lean widget shell for tool "${tool.metadata.name}": ${errorMessage}`);
          }
        }),
      );
      this.logger.info(
        `Pre-compiled ${inlineCompiledCount}/${inlineTools.length} lean widget shell(s) for inline mode tools`,
      );
    }

    // Pre-compile hybrid widget shells for hybrid mode tools
    const hybridTools = toolsWithUI.filter(
      (t) => t.metadata.ui && t.metadata.ui.servingMode === 'hybrid' && t.metadata.ui.template,
    );

    if (hybridTools.length > 0) {
      let hybridCompiledCount = 0;
      await Promise.all(
        hybridTools.map(async (tool) => {
          const uiConfig = tool.metadata.ui;
          if (!uiConfig) {
            this.logger.warn(
              `Skipping hybrid widget pre-compile for tool "${tool.metadata.name}" due to missing ui config`,
            );
            return;
          }
          try {
            await this.toolUIRegistry.compileHybridWidgetAsync({
              toolName: tool.metadata.name,
              template: uiConfig.template,
              uiConfig,
            });
            hybridCompiledCount++;
            this.logger.verbose(`Compiled hybrid widget shell for tool: ${tool.metadata.name}`);
          } catch (error) {
            const errorMessage = error instanceof Error ? error.message : String(error);
            this.logger.error(
              `Failed to compile hybrid widget shell for tool "${tool.metadata.name}": ${errorMessage}`,
            );
          }
        }),
      );
      this.logger.info(
        `Pre-compiled ${hybridCompiledCount}/${hybridTools.length} hybrid widget shell(s) for hybrid mode tools`,
      );
    }
  }

  /**
   * Build the custom auth-UI registry (#469) PER SCOPE from this scope's resolved
   * auth options (`auth.ui` / `auth.extras`). The parent multi-app scope reads
   * the top-level `@FrontMcp({ auth })`, and each `splitByApp` scope reads its own
   * `@App({ auth })` (resolved into `this.metadata.auth` by `normalizeAppScope`),
   * so a split app gets ITS OWN auth UI.
   *
   * No-op (leaves {@link authUiRegistry} undefined) when neither is declared, so
   * the OAuth flows serve the built-in HTML pages exactly as before. When custom
   * slots ARE declared, their SSR + hydration bundles are pre-built at startup
   * (mirroring `compileUIWidgets`); build failures are logged + cached, never
   * thrown, so a broken component degrades to the built-in page.
   */
  private async buildAuthUiRegistry(): Promise<void> {
    // `auth.ui` / `auth.extras` exist only on local/remote auth modes; read them
    // defensively so public/transparent (and absent auth) are a clean no-op.
    const auth = this.metadata.auth as
      | { ui?: import('@frontmcp/auth').AuthUiMap; extras?: import('@frontmcp/auth').AuthExtrasMap }
      | undefined;
    const authUi = auth?.ui;
    const authExtras = auth?.extras;
    const uiSlotCount = authUi ? Object.keys(authUi).length : 0;
    const extraCount = authExtras ? Object.keys(authExtras).length : 0;
    if (uiSlotCount === 0 && extraCount === 0) return;

    // Anchor `auth.ui[slot]` relative paths to the config file that declared this
    // scope's auth (the `@FrontMcp`/`@App` source dir captured at decoration
    // time). Fall back to process.cwd() with a warning when capture failed.
    let sourceDir = this.metadata.__sourceDir;
    if (!sourceDir) {
      sourceDir = process.cwd();
      if (uiSlotCount > 0) {
        this.logger.warn(
          `auth.ui paths will resolve against process.cwd() ("${sourceDir}") — the declaring ` +
            `@FrontMcp/@App source directory could not be captured. Use absolute paths if this is wrong.`,
        );
      }
    }

    const registry = new AuthUiRegistry(this.logger);
    registry.registerAuthUiMap(authUi, sourceDir);
    registry.registerAuthExtrasMap(authExtras);
    // In dev / offline, map @frontmcp/ui/auth (not on esm.sh in a monorepo) to a
    // served URL via the same cdnOverrides used for tool widgets.
    const cdnOverrides = this.metadata.ui?.cdnOverrides;
    if (cdnOverrides && Object.keys(cdnOverrides).length > 0) {
      registry.setResolverOverrides(cdnOverrides);
    }
    // warmup() is a best-effort preflight that caches per-slot errors; guard it
    // anyway so a broken custom page can never take scope init (and the server)
    // down — the OAuth flows then serve the built-in pages for broken slots.
    try {
      await registry.warmup();
    } catch (error) {
      this.logger.warn(
        'Auth-UI registry warmup encountered errors — affected custom pages will fall back to built-in',
        { error: error instanceof Error ? error.message : String(error) },
      );
    }
    this.authUiRegistry = registry;
    this.logger.verbose(`Auth-UI registry initialized (${uiSlotCount} slot file(s), ${extraCount} extra(s))`);
  }

  private formatScopeSummary(): string {
    const entries: string[] = [];
    const add = (count: number, label: string) => {
      if (count > 0) entries.push(`${count} ${label}${count !== 1 ? 's' : ''}`);
    };
    add(this.scopeApps.getApps().length, 'app');
    add(this.scopeTools.getTools(true).length, 'tool');
    add(this.scopeResources.getResources().length, 'resource');
    add(this.scopePrompts.getPrompts().length, 'prompt');
    add(this.scopeAgents.getAgents().length, 'agent');
    add(this.scopeSkills.getSkills().length, 'skill');
    if (this._scopeJobs) add(this._scopeJobs.getJobs().length, 'job');
    if (this._scopeWorkflows) add(this._scopeWorkflows.getWorkflows().length, 'workflow');
    if (this._scopeChannels) add(this._scopeChannels.size, 'channel');
    return entries.length > 0 ? entries.join(', ') : 'empty';
  }

  private get defaultScopeProviders() {
    return [
      {
        scope: ProviderScope.GLOBAL,
        name: 'ScopeEntry',
        provide: ScopeEntry,
        useValue: this,
      },
      {
        scope: ProviderScope.GLOBAL,
        name: 'Scope',
        provide: Scope,
        useValue: this,
      },
      {
        scope: ProviderScope.GLOBAL,
        name: 'FrontMcpLogger',
        provide: FrontMcpLogger,
        useValue: this.logger,
      },
      // FrontMcpContextStorage is GLOBAL because it manages the AsyncLocalStorage,
      // not the per-request data. Access the actual FrontMcpContext via FRONTMCP_CONTEXT token.
      {
        scope: ProviderScope.GLOBAL,
        name: 'FrontMcpContextStorage',
        provide: FrontMcpContextStorage,
        useClass: FrontMcpContextStorage,
      },
      // FrontMcpContextProvider is a factory that retrieves from AsyncLocalStorage
      FrontMcpContextProvider,
    ];
  }

  get auth(): FrontMcpAuth {
    return this.scopeAuth.getPrimary();
  }

  get hooks(): HookRegistry {
    return this.scopeHooks;
  }

  get authProviders(): AuthRegistry {
    return this.scopeAuth;
  }

  get providers() {
    return this.scopeProviders;
  }

  get apps(): AppRegistry {
    return this.scopeApps;
  }

  get tools(): ToolRegistry {
    return this.scopeTools;
  }

  get toolUI(): ToolUIRegistry {
    return this.toolUIRegistry;
  }

  get authUi(): AuthUiRegistry | undefined {
    return this.authUiRegistry;
  }

  get resources(): ResourceRegistry {
    return this.scopeResources;
  }

  get prompts(): PromptRegistry {
    return this.scopePrompts;
  }

  get agents(): AgentRegistry {
    return this.scopeAgents;
  }

  get skills(): SkillRegistry {
    return this.scopeSkills;
  }

  get jobs(): JobRegistry | undefined {
    return this._scopeJobs;
  }

  get workflows(): WorkflowRegistry | undefined {
    return this._scopeWorkflows;
  }

  get channels(): ChannelRegistry | undefined {
    return this._scopeChannels;
  }

  get channelNotifications(): ChannelNotificationService | undefined {
    return this._channelNotificationService;
  }

  get channelEventBus(): ChannelEventBus | undefined {
    return this._channelEventBus;
  }

  /**
   * Get the skill session manager for tool authorization enforcement.
   * Returns undefined if skill sessions are not enabled.
   *
   * Skill sessions provide:
   * - Tool allowlists: Only tools declared in the active skill can be called
   * - Policy modes: strict (block), approval (prompt), or permissive (warn)
   * - Rate limiting: Optional per-session tool call limits
   *
   * Enable via @FrontMcp metadata: `skills: { sessions: { enabled: true, defaultPolicyMode: 'strict' } }`
   */
  get skillSession(): SkillSessionManager | undefined {
    return this._skillSession;
  }

  get plugins(): PluginRegistry | undefined {
    return this.scopePlugins;
  }

  get notifications(): NotificationService {
    return this.notificationService;
  }

  /**
   * Get the elicitation store for distributed elicitation support.
   *
   * Lazily initializes the store using the elicitation store factory:
   * - Redis: Uses RedisElicitationStore for distributed deployments
   * - In-memory: Uses InMemoryElicitationStore for single-node/dev
   * - Edge runtime without Redis: Throws error (Edge functions are stateless)
   *
   * @see createElicitationStore for factory implementation details
   */
  get elicitationStore(): ElicitationStore | undefined {
    return this._elicitationStore;
  }

  /**
   * Background-tasks record store (MCP 2025-11-25).
   * Returns `undefined` if tasks are not enabled for this scope.
   */
  get taskStore(): TaskStore | undefined {
    return this._taskStore;
  }

  /**
   * Per-process task registry (AbortControllers + capability projection).
   * Returns `undefined` if tasks are not enabled for this scope.
   */
  get tasks(): TaskRegistry | undefined {
    return this._taskRegistry;
  }

  /**
   * Get the EventStore for SSE resumability support.
   *
   * Returns undefined if EventStore is not enabled.
   * When enabled, clients can reconnect and resume missed SSE messages
   * using the Last-Event-ID header per the MCP protocol.
   *
   * Enable via @FrontMcp metadata:
   * ```typescript
   * transport: {
   *   eventStore: {
   *     enabled: true,
   *     provider: 'memory',  // or 'redis'
   *   }
   * }
   * ```
   */
  get eventStore(): EventStore | undefined {
    return this._eventStore;
  }

  /**
   * Guard manager for rate limiting, concurrency control, IP filtering, and timeout.
   * Returns undefined if throttle is not configured or disabled.
   */
  get rateLimitManager(): GuardManager | undefined {
    return this._rateLimitManager;
  }

  /**
   * Health service for liveness and readiness probes.
   * Returns undefined if health endpoints are disabled.
   */
  get healthService(): HealthService | undefined {
    return this._healthService;
  }

  /**
   * Authorities engine for evaluating RBAC/ABAC/ReBAC policies.
   * Returns undefined if AuthoritiesPlugin is not configured.
   * Used by flow `checkEntryAuthorities` stages.
   */
  get authoritiesEngine(): import('@frontmcp/auth').AuthoritiesEngine | undefined {
    return this._authoritiesEngine;
  }

  /**
   * Authorities context builder for creating evaluation contexts from AuthInfo.
   * Returns undefined if AuthoritiesPlugin is not configured.
   */
  get authoritiesContextBuilder(): import('@frontmcp/auth').AuthoritiesContextBuilder | undefined {
    return this._authoritiesContextBuilder;
  }

  /**
   * Scope mapping for converting authority denials to OAuth scope challenges.
   * Returns undefined if no scopeMapping is configured.
   */
  get authoritiesScopeMapping(): import('@frontmcp/auth').AuthoritiesScopeMapping | undefined {
    return this._authoritiesScopeMapping;
  }

  /**
   * Collect all supported OAuth scopes from base OIDC scopes and
   * tool-level authProvider scope declarations.
   * Used by PRM endpoint to populate `scopes_supported` (RFC 9728).
   */
  getAllSupportedScopes(): string[] {
    const scopes = new Set<string>(['openid', 'profile', 'email']);

    // Helper to extract scopes from any entry's authProviders metadata
    const collectScopes = (metadata: unknown) => {
      const authProviders = (metadata as Record<string, unknown>)?.['authProviders'];
      if (!Array.isArray(authProviders)) return;
      for (const ap of authProviders) {
        if (typeof ap === 'object' && ap !== null && 'scopes' in ap) {
          const apScopes = (ap as Record<string, unknown>)['scopes'];
          if (Array.isArray(apScopes)) {
            apScopes.forEach((s) => {
              if (typeof s === 'string') scopes.add(s);
            });
          }
        }
      }
    };

    // Collect from all entry types that can declare authProviders
    for (const tool of this.scopeTools.getTools(true)) {
      collectScopes(tool.metadata);
    }
    for (const resource of this.scopeResources.getResources()) {
      collectScopes(resource.metadata);
    }
    for (const prompt of this.scopePrompts.getPrompts()) {
      collectScopes(prompt.metadata);
    }
    for (const agent of this.scopeAgents.getAgents()) {
      collectScopes((agent as unknown as Record<string, unknown>)['metadata']);
    }

    return [...scopes].sort();
  }

  /**
   * Register the authorities engine and context builder.
   * Called by AuthoritiesPlugin during plugin initialization.
   * @internal
   */
  registerAuthoritiesEngine(
    engine: import('@frontmcp/auth').AuthoritiesEngine,
    contextBuilder: import('@frontmcp/auth').AuthoritiesContextBuilder,
  ): void {
    this._authoritiesEngine = engine;
    this._authoritiesContextBuilder = contextBuilder;
  }

  /**
   * Initialize authorities engine from @FrontMcp({ authorities: { ... } }) config.
   * Built-in integration — no plugin required.
   * @internal
   */
  private initAuthoritiesFromConfig(): void {
    const config = (this.metadata as unknown as Record<string, unknown>)['authorities'] as
      | Record<string, unknown>
      | undefined;
    if (!config) return;

    try {
      const {
        AuthoritiesEngine,
        AuthoritiesContextBuilder,
        AuthoritiesProfileRegistry,
        AuthoritiesEvaluatorRegistry,
      } = require('@frontmcp/auth');

      const profileRegistry = new AuthoritiesProfileRegistry();
      if (config['profiles']) profileRegistry.registerAll(config['profiles']);

      const evaluatorRegistry = new AuthoritiesEvaluatorRegistry();
      if (config['evaluators']) evaluatorRegistry.registerAll(config['evaluators']);

      this._authoritiesEngine = new AuthoritiesEngine(profileRegistry, evaluatorRegistry);
      this._authoritiesContextBuilder = new AuthoritiesContextBuilder({
        claimsMapping: config['claimsMapping'],
        claimsResolver: config['claimsResolver'],
        relationshipResolver: config['relationshipResolver'],
      });

      this._authoritiesScopeMapping = config['scopeMapping'] as
        | import('@frontmcp/auth').AuthoritiesScopeMapping
        | undefined;

      this.logger.verbose('Scope: authorities engine initialized from config');
    } catch (error) {
      const msg = error instanceof Error ? error.message : String(error);
      this.logger.warn(`Scope: authorities init failed — ${msg}`);
    }
  }

  /**
   * Fail-fast: if any registered entry has 'authorities' metadata but no
   * AuthoritiesEngine is configured, throw at startup instead of silently
   * allowing unrestricted access.
   * @internal
   */
  private validateAuthoritiesConfig(): void {
    if (this._authoritiesEngine && this._authoritiesContextBuilder) return; // Enforcement fully configured

    const entriesWithAuthorities: string[] = [];

    // Check tools
    for (const tool of this.scopeTools.getTools(true)) {
      const metadata = tool.metadata as unknown as Record<string, unknown>;
      if (metadata['authorities']) {
        entriesWithAuthorities.push(`Tool "${tool.name}"`);
      }
    }

    // Check resources
    for (const resource of this.scopeResources.getResources()) {
      const metadata = resource.metadata as unknown as Record<string, unknown>;
      if (metadata['authorities']) {
        entriesWithAuthorities.push(`Resource "${resource.name}"`);
      }
    }

    // Check prompts
    for (const prompt of this.scopePrompts.getPrompts()) {
      const metadata = prompt.metadata as unknown as Record<string, unknown>;
      if (metadata['authorities']) {
        entriesWithAuthorities.push(`Prompt "${prompt.name}"`);
      }
    }

    // Check agents
    for (const agent of this.scopeAgents.getAgents()) {
      const metadata = (agent as unknown as Record<string, unknown>)['metadata'] as Record<string, unknown> | undefined;
      if (metadata?.['authorities']) {
        entriesWithAuthorities.push(`Agent "${(agent as unknown as Record<string, unknown>)['name'] ?? 'unknown'}"`);
      }
    }

    // Check skills (include hidden — a hidden skill with authorities still
    // requires an engine to enforce its policy on direct load/read).
    for (const skill of this.scopeSkills.getSkills(true)) {
      const metadata = skill.metadata as unknown as Record<string, unknown>;
      if (metadata['authorities']) {
        entriesWithAuthorities.push(`Skill "${skill.name}"`);
      }
    }

    if (entriesWithAuthorities.length > 0) {
      const names = entriesWithAuthorities.slice(0, 5).join(', ');
      const suffix = entriesWithAuthorities.length > 5 ? ` and ${entriesWithAuthorities.length - 5} more` : '';
      throw new AuthConfigurationError(
        `Authorities configuration required: ${names}${suffix} declare 'authorities' metadata ` +
          `but authorities enforcement is not fully configured (engine/context builder missing). ` +
          `Add 'authorities: { claimsMapping: {...}, profiles: {...} }' ` +
          `to your @FrontMcp() decorator to enable enforcement, or remove 'authorities' from entry metadata.`,
        { suggestion: 'Add authorities config to @FrontMcp() or remove authorities from entry metadata' },
      );
    }
  }

  /**
   * Register the sendElicitationResult system tool.
   * This tool is hidden by default and only shown to clients that don't support elicitation.
   */
  private registerSendElicitationResultTool(_scopeRef: EntryOwnerRef): void {
    try {
      const toolRecord = normalizeTool(SendElicitationResultTool);
      const systemToolEntry = new ToolInstance(toolRecord, this.scopeProviders, {
        kind: 'scope',
        id: '_system',
        ref: SendElicitationResultTool,
      });
      this.scopeTools.registerToolInstance(systemToolEntry);
      this.logger.verbose('Registered sendElicitationResult system tool');
    } catch (error) {
      // Log error but don't fail scope initialization
      this.logger.warn(
        `Failed to register sendElicitationResult tool: ${error instanceof Error ? error.message : String(error)}`,
      );
    }
  }

  /**
   * Get pagination configuration for list operations.
   * Returns the parsed pagination config from @FrontMcp metadata.
   */
  get pagination() {
    return this.metadata.pagination;
  }

  registryFlows(...flows: FlowType[]) {
    return this.scopeFlows.registryFlows(flows);
  }

  runFlow<Name extends FlowName>(
    name: Name,
    input: FlowInputOf<Name>,
    deps?: Map<Token, Type>,
  ): Promise<FlowOutputOf<Name> | undefined> {
    return this.scopeFlows.runFlow(name, input, deps);
  }

  async runFlowForOutput<Name extends FlowName>(
    name: Name,
    input: FlowInputOf<Name>,
    deps?: Map<Token, Type>,
  ): Promise<FlowOutputOf<Name>> {
    const result = await this.scopeFlows.runFlow(name, input, deps);
    if (result) {
      return result;
    }
    throw new FlowExitedWithoutOutputError();
  }

  /**
   * Shut down the scope and release resources.
   * Disconnects channel service connectors, unsubscribes event handlers,
   * and clears the channel event bus.
   */
  async shutdown(): Promise<void> {
    if (this.haManager) {
      try {
        await this.haManager.stop();
      } catch (err) {
        this.logger.error('[HA] Manager shutdown failed', { error: err });
      }
    }
    if (this._channelTeardown) {
      try {
        await this._channelTeardown();
      } catch (err) {
        this.logger.error('Channel teardown failed', { error: err });
      }
      this._channelTeardown = undefined;
    }
  }

  /**
   * Dispose of this scope, cleaning up all registries and native resources.
   * Call before process exit to prevent native mutex crashes from addons
   * (e.g., ONNX runtime) whose threads are still running during teardown.
   */
  async dispose(): Promise<void> {
    this.scopeProviders.dispose();
    if (this.notificationService) {
      await this.notificationService.destroy();
    }
  }
}
