import express, { Request, Response, NextFunction, RequestHandler } from 'express';
import { existsSync } from 'fs';
import { join } from 'path';
import { Repository, BrowserSessionRepo } from '../db/repository.js';
import { logger } from '../logger.js';
import { loadConfig } from '../config.js';
import { ConfigManager } from '../config-manager.js';
import { mountSetupAndConfigApis } from './setup-config-mount.js';
import { mountPiecesApi } from './pieces-api.js';
import { resolveSpaceFolder } from '../spaces/folder-resolver.js';
import { mountToolsApi } from './tools-api.js';
import { mountSkillsApi } from './skills-api.js';
import { mountNotificationsApi } from './notifications-api.js';
import { mountScheduledTasksApi } from './scheduled-tasks-api.js';
import { mountBrandingApi } from './branding-api.js';
import { createBrowserApi } from './browser-api.js';
import { createBrowserSessionApi } from './browser-session-api.js';
import { createSubtaskActivityRouter } from './subtask-activity-api.js';
import { createDelegateRunsRouter } from './delegate-runs-api.js';
import { createUsageRouter } from './usage-api.js';
import { SessionManager } from '../engine/browser-session.js';
import { setupNovncWebSocketProxy } from './novnc-proxy.js';
import { mountNovncStatic, buildNovncSessionAuthorizer } from './novnc-mount.js';
import { setSessionManager } from '../engine/tools/browser.js';
import { setUserFolderToolDeps } from '../engine/tools/user-folder.js';
import { setSkillToolDeps } from '../engine/tools/skills.js';
import { setAppDocsDeps } from '../engine/tools/app-docs.js';
import { requireAuth, requireAdmin } from './auth.js';
import { resolveAuthActivation, mountAuthPipeline } from './auth-subsystem.js';
import { mountAdminApi } from './admin-api.js';
import { mountAdminGatewayKeysApi } from './admin-gateway-keys-mount.js';
import { mountUsersApi, mountUsersPickableApi } from './users-api.js';
import { mountShareApi } from './share-api.js';
import { mountAppShareApi } from './app-share-api.js';
import { mountMetaApi } from './meta-api.js';
import { securityHeadersMiddleware } from './security-headers.js';
import { mountLocalTasksApi } from './local-tasks-api.js';
import { mountJobsApi } from './jobs-api.js';
import { mountUiStatic } from './ui-static.js';
import { findPieceFile } from './pieces-api.js';
import { mountLocalFilesApi } from './local-files-api.js';
import { mountSubtaskFilesApi } from './subtask-files-api.js';
import { createUserFolderApi } from './user-folder-api.js';
import { createMemoryApi } from './memory-api.js';
import { createReflectionApi } from './reflection-api.js';
import { createDashboardApi } from './dashboard-api.js';
import { createSpaceApi } from './space-api.js';
import { setupMcpSubsystem } from './mcp-subsystem.js';
import { setupSshSubsystem, type SshConsoleDeps } from './ssh-subsystem.js';
import { mountAppHarnessApi } from './app-harness-api.js';
import { createCrossCalendarApi } from './cross-calendar-api.js';
import { registerShutdownHook, installSignalHandlers } from './shutdown.js';
import { createBackendStatusRegistry, type BackendStatusRegistry } from '../engine/backend-status-registry.js';
import type { WorkerMetrics } from '../metrics/worker-metrics.js';
import { setupMetricsSubsystem } from './metrics-subsystem.js';
import { buildDirectProbe, buildProxyProbe } from '../engine/backend-probes.js';
import { startTrashCleanup } from '../user-folder/trash-cleanup.js';
import { userPiecesDir } from '../user-folder/paths.js';
import { startReflectionRetentionSweep } from '../engine/reflection/retention.js';
import type { AuthConfig } from '../config.js';
import { attachConsoleWs } from './console-ws-api.js';
import type { GatewayMountHandle } from './gateway-mount.js';
import { mountBridgeGateway } from './bridge-gateway-mount.js';
import { setupA2aSubsystem } from './a2a-subsystem.js';
import { createServer as createHttpsServer } from 'https';
import { X509Certificate } from 'crypto';
import { mergeServerConfig, resolveListenPort } from '../server/config.js';
import { resolveTlsOptions } from '../net/tls-options.js';
import { createHttpRedirectServer } from '../net/http-redirect.js';

export interface CoreServerOptions {
  repo: Repository;
  worktreeDir?: string;
  configuredRepos?: string[];
  generateTitle?: (body: string, ownerId?: string) => Promise<string>;
  selectPiece?: (body: string, fileNames: string[], userId?: string) => Promise<string>;
  /** On-demand prompt coach for the create dialog (stateless draft evaluation). */
  evaluatePrompt?: (req: {
    instruction: string;
    piece?: string;
    userId: string;
    signal?: AbortSignal;
  }) => Promise<unknown>;
  configManager?: ConfigManager;
  piecesDir?: string;
  customPiecesDir?: string;
  scheduler?: import('../scheduler.js').Scheduler;
  authConfig?: AuthConfig;
  /** Directory for user-uploaded branding assets (logos, favicons). Must be gitignored. */
  brandingDir?: string;
  workerManager?: import('../worker-manager.js').WorkerManager;
  /** SkillCatalog instance for the /api/skills CRUD endpoints. */
  skillCatalog?: import('../engine/skills.js').SkillCatalog;
  /** Notifications V2 — PushService instance. null disables push API endpoints
   *  (they return 503), but the routes are still mounted so the UI can
   *  distinguish "feature off" from "endpoint missing". */
  pushService?: import('../push-service.js').PushService | null;
  /** Notifications V2 — VAPID key store. Required when pushService is set. */
  vapidStore?: import('../vapid-store.js').VapidKeyStore | null;
  /**
   * TCP port the bridge will listen on. Forwarded to admin endpoints
   * (e.g. /api/admin/gateway/status) so the UI can label the gateway
   * mount with the correct port. Threaded through from
   * `startCoreServer(opts, port)` so a non-default port doesn't show
   * up as the PORT env-var guess (which can be stale or unset).
   */
  listenPort?: number;
}


export function createCoreServer(opts: CoreServerOptions): {
  app: express.Application;
  browserSessionManager: SessionManager | null;
  authenticateUpgrade?: import('./auth.js').UpgradeAuthChecker;
  authorizeNovncSession: import('./novnc-proxy.js').NovncSessionAuthorizer;
  sshConsole: SshConsoleDeps | null;
  /** Singleton NodeStatus cache used by the Side Info Panel's node-status widget. */
  backendStatusRegistry: BackendStatusRegistry;
  /** Phase 3b: Prometheus metrics handle (null when provider.metrics.enabled=false). */
  workerMetrics: WorkerMetrics | null;
  /**
   * Phase 3c — same-process AAO Gateway mount handle. Null when no
   * ConfigManager was supplied (no hot-reload available). Use this to
   * read current state for the admin status endpoint.
   */
  gatewayMount: GatewayMountHandle | null;
  /** True when an OAuth provider or local auth is active. False = no-auth mode. */
  authActive: boolean;
  /**
   * Resolved server config snapshot — computed once with the real listen port
   * so both the cookie-secure decision (inside createCoreServer) and the
   * listener branch (inside startCoreServer) share the SAME object.
   * A config hot-reload between the two would otherwise cause tls.enabled to
   * disagree between the cookie flag and the actual listener type.
   */
  serverConfig: ReturnType<typeof mergeServerConfig>;
} {
  const { repo, worktreeDir } = opts;
  const app = express();

  // Don't advertise the framework/version (minor info-leak, free to drop).
  app.disable('x-powered-by');

  // Baseline hardening headers on every response (nosniff, X-Frame-Options,
  // Referrer-Policy, and HSTS over TLS). File-serving endpoints add
  // `Content-Security-Policy: sandbox` on top (see setUntrustedFileResponseHeaders)
  // to neutralize stored XSS from agent/user-authored workspace files.
  //
  // HSTS policy is read lazily: serverCfg (with the resolved tls block) is built
  // further down, but the middleware only runs the thunk per request — long
  // after serverCfg is assigned. Default 'clear' until then. HSTS is opt-in
  // (server.tls.hsts); when off we emit `max-age=0` to un-pin browsers that an
  // earlier build pinned (see security-headers.ts).
  let hstsPolicy: import('./security-headers.js').HstsPolicy = 'clear';
  app.use(securityHeadersMiddleware(() => hstsPolicy));

  // リバースプロキシ背後で secure cookie / X-Forwarded-Proto を正しく処理
  if (opts.authConfig?.secureCookie) {
    app.set('trust proxy', 1);
  }

  // Phase 3b: Prometheus /metrics endpoint. Mounted BEFORE any auth
  // middleware so the standard Prometheus scrape job works without a
  // session cookie. Restrict scrape access at the reverse proxy /
  // firewall layer (see help doc). Disabled by
  // provider.metrics.enabled=false in config.yaml.
  const { workerMetrics, sharedPromRegistry } = setupMetricsSubsystem(app, opts.configManager);

  // JSON body parser for Config/Pieces/Memory/Reflection API endpoints
  app.use('/api/config', express.json());
  app.use('/api/pieces', express.json());
  app.use('/api/local/memory', express.json());
  app.use('/api/local/reflection', express.json());

  // === Auth setup === (see auth-subsystem.ts)
  // Fail-closed activation decision: throws on a partially configured OAuth
  // provider; seeds the synthetic 'local' user in no-auth mode.
  const { authActive, localEnabled } = resolveAuthActivation(repo, opts.authConfig);
  let authenticateUpgrade: import('./auth.js').UpgradeAuthChecker | undefined;

  // Resolve server config ONCE here with the real listen port (threaded in via
  // opts.listenPort by startCoreServer). Both the cookie-secure decision below
  // and the listener branch in startCoreServer consume this same snapshot so a
  // config hot-reload between the two calls cannot produce a mismatch between
  // tls.enabled and the cookie secure flag.
  const serverCfg = mergeServerConfig(loadConfig().server, {
    freshInstall: false,
    // startCoreServer threads the resolved port in via listenPort; the fallback
    // (for callers that bypass it) applies the same PORT-env > config precedence.
    httpsPort: opts.listenPort ?? resolveListenPort(process.env['PORT'], loadConfig().server?.port),
  });

  // Now that the TLS block is resolved, set the HSTS policy the lazy
  // securityHeadersMiddleware thunk reads. Only 'enable' when HTTPS is on AND
  // the operator opted in; otherwise 'clear' (emit max-age=0) so a previously
  // pinned browser recovers the next time it loads the app over HTTPS.
  hstsPolicy = serverCfg.tls.enabled && serverCfg.tls.hsts ? 'enable' : 'clear';

  if (authActive) {
    // Session/passport middleware + /auth routes + /api/* guards
    // (see auth-subsystem.ts — registered here, BEFORE all later mounts).
    authenticateUpgrade = mountAuthPipeline(app, {
      repo,
      authConfig: opts.authConfig,
      configManager: opts.configManager,
      localEnabled,
      tlsEnabled: serverCfg.tls.enabled,
    });
  }

  // Admin user management API (always mounted; protected by requireAdmin when auth is active)
  app.use('/api/admin', express.json());
  mountAdminApi(app, repo, authActive, loadConfig()?.userFolderRoot ?? './data/users');

  // AAO Gateway Phase 2a: admin-only CRUD over gateway_virtual_keys
  // (see admin-gateway-keys-mount.ts — refuses to mount when auth is off).
  mountAdminGatewayKeysApi(app, { repo, authActive });

  // /api/users/me/* routes — current viewer info (gated by requireAuth when auth is active)
  mountUsersApi(app, repo, authActive);

  // /api/users/pickable — active-user picker for space-member invites.
  // Gated by requireAuth (NOT requireAdmin): any authed user may invite. Returns
  // [] in no-auth mode (no other users to invite).
  mountUsersPickableApi(app, repo, authActive);

  // --- Share API (public + authenticated routes) ---
  mountShareApi(app, repo);

  // --- 公開アプリ共有 API（read-only・認証なし）---
  // space-api と同じワークスペース解決（loadConfig().worktreeDir）を使う。
  mountAppShareApi(app, repo, worktreeDir ?? loadConfig().worktreeDir ?? './data/worktrees');

  // Root redirect + UI static hosting + app-harness page (see ui-static.ts).
  mountUiStatic(app);

  // Version + repos info endpoints (see meta-api.ts). Mounted here to preserve
  // ordering relative to the `/api/repos` requireAuth guard registered above.
  mountMetaApi(app, { repo, configuredRepos: opts.configuredRepos });

  // Per-user browser session profile repo (envelope-encrypted storageState).
  // Created up-front so APIs that bind a profile to a task (local + scheduled)
  // can run an owner-scoped check before persisting.
  const sessRepo = new BrowserSessionRepo(repo.getDb());

  const mcpCatalogDeps = setupMcpSubsystem(app, {
    repo,
    authActive,
    workerManager: opts.workerManager,
    callbackBaseUrl: deriveCallbackBaseUrl(opts.authConfig),
  });

  const sshConsole = setupSshSubsystem(app, { repo, authActive, authenticateUpgrade });

  // --- Local tasks API ---
  mountLocalTasksApi(app, {
    repo,
    worktreeDir,
    userFolderRoot: loadConfig().userFolderRoot ?? './data/users',
    authActive,
    generateTitle: opts.generateTitle,
    selectPiece: opts.selectPiece,
    evaluatePrompt: opts.evaluatePrompt,
    pieceExists: opts.piecesDir
      ? (name: string, ownerId?: string) => {
          // Mirror the worker's per-user → global-custom → builtin resolution order.
          // 1. Owner's per-user dir (matches what worker uses at job run time).
          // No-auth tasks (ownerId null) fall back to 'local', mirroring the worker.
          const ufl = loadConfig().userFolderRoot ?? './data/users';
          const ownerForPieces = ownerId ?? 'local';
          if (existsSync(join(userPiecesDir(ufl, ownerForPieces), `${name}.yaml`))) return true;
          // 2. Global-custom + builtin via existing helper.
          return findPieceFile(name, opts.piecesDir!, opts.customPiecesDir) !== null;
        }
      : undefined,
    sessRepo,
    getMaxUploadMb: opts.configManager
      ? () => opts.configManager!.getConfig().tools?.taskUploadMaxSizeMb ?? 50
      : () => loadConfig().tools?.taskUploadMaxSizeMb ?? 50,
    // Package-request approvals install into the task's space overlay.
    getPythonPackagesConfig: () => loadConfig().pythonPackages,
  });

  // --- Local files API ---
  mountLocalFilesApi(app, repo, { authActive });

  // --- Subtask activity API ---
  app.use('/api/local/tasks', createSubtaskActivityRouter(repo));
  app.use('/api/local/tasks', createDelegateRunsRouter(repo));
  app.use('/api/usage', createUsageRouter(repo, { authActive }));
  // 横断（全スペース）カレンダー: GET /api/calendar/cross
  app.use('/api/calendar', createCrossCalendarApi({ repo, authActive }));

  // --- A2A OAuth2 Authorization Server (see a2a-subsystem.ts; no-op unless a2a.enabled) ---
  setupA2aSubsystem(app, { repo, authActive });

  // --- Subtask files API (listing MUST come before wildcard) ---
  mountSubtaskFilesApi(app, repo);

  // --- Job detail API (see jobs-api.ts) ---
  mountJobsApi(app, { repo, authActive });

  // NOTE: bridge `/health` (`{status:'ok'}`) is intentionally registered
  // LATER — see the "bridge /health fallback" block below the gateway
  // mount. Express matches handlers in registration order, so the
  // gateway gate middleware must register before this handler to be
  // able to dispatch `/health` to the gateway sub-app when running
  // (LiteLLM-shape JSON — CRITICAL-3 fix).

  // === Browser setup wizard (fresh no-auth install onboarding) + Config API ===
  // (see setup-config-mount.ts)
  if (opts.configManager) {
    mountSetupAndConfigApis(app, {
      configManager: opts.configManager,
      authActive,
      listenPort: opts.listenPort,
    });
  }

  // Branding 公開 API (GET は認証不要)。アップロード系は admin のみ。
  // 保存先は data/branding/ 配下で .gitignore 済み → git pull 時にユーザーのカスタム資産が失われない。
  const brandingDir = opts.brandingDir ?? join(process.cwd(), 'data', 'branding');
  const brandingGuard: RequestHandler = authActive ? requireAdmin : (_req, _res, next) => next();
  mountBrandingApi(app, opts.configManager, { brandingDir, adminGuard: brandingGuard });

  if (opts.piecesDir) {
    const piecesUserFolderRoot = loadConfig().userFolderRoot ?? './data/users';
    mountPiecesApi(app, {
      piecesDir: opts.piecesDir,
      customPiecesDir: opts.customPiecesDir,
      userPiecesRootDir: piecesUserFolderRoot,
      // Spaces foundation: resolve a request's spaceId to its pieces folder.
      // No spaceId on the request => legacy per-user resolution (unchanged).
      resolveSpaceFolder: async (spaceId: string, viewer) => {
        const space = await repo.getSpace(spaceId, { viewer: viewer as Express.User | undefined });
        return space ? resolveSpaceFolder(space, piecesUserFolderRoot) : null;
      },
    });
  }

  mountToolsApi(app, {
    authActive,
    requireAuth,
    mcp: mcpCatalogDeps,
  });

  if (opts.skillCatalog) {
    const skillsUserFolderRoot = loadConfig().userFolderRoot ?? './data/users';
    mountSkillsApi(app, {
      skillCatalog: opts.skillCatalog,
      requireAuth,
      requireAdmin,
      authActive,
      auditLog: async (jobId, action, actor, detail) => {
        try { await repo.addAuditLog(jobId, action, actor, detail); } catch {}
      },
      // Spaces foundation: resolve a request's spaceId to its skills folder,
      // gated on the viewer's visibility of the space. No spaceId => legacy
      // per-user resolution (unchanged).
      resolveSpaceFolder: async (spaceId, viewer) => {
        const space = await repo.getSpace(spaceId, { viewer });
        return space ? resolveSpaceFolder(space, skillsUserFolderRoot) : null;
      },
    });
  }

  // Notifications V2 (Web Push). Always mounted — the route returns 503
  // when pushService is null so the UI can render a clear status.
  // Spec: docs/superpowers/specs/2026-05-28-browser-notifications-v2-webpush.md.
  mountNotificationsApi(app, {
    repo,
    pushService: opts.pushService ?? null,
    vapidStore: opts.vapidStore ?? null,
    requireAuth: authActive
      ? requireAuth
      : (_req, _res, next) => {
          // Auth disabled (local mode): synthesize a 'local' user so
          // the per-user state machine still functions.
          (_req as unknown as { user: { id: string; role: string } }).user = {
            id: 'local',
            role: 'admin',
          };
          next();
        },
  });

  if (opts.scheduler) {
    app.use('/api/scheduled-tasks', express.json());
    mountScheduledTasksApi(app, repo, opts.scheduler, { sessRepo, authActive });
  }

  // Browser session API
  const browserSessionManager = SessionManager.isAvailable()
    ? new SessionManager(loadConfig().browser ?? {})
    : null;
  setSessionManager(browserSessionManager);
  app.use('/api/local/browser/sessions', express.json(), createBrowserApi(browserSessionManager, repo));

  // Per-user browser session profile CRUD (envelope-encrypted storageState).
  // Distinct from /api/local/browser/sessions (which manages live noVNC sessions).
  const masterKeyPath = loadConfig().secrets?.masterKeyPath ?? './data/secrets/master.key';
  app.use(
    '/api/browser-sessions',
    express.json(),
    createBrowserSessionApi({
      sessRepo,
      sessionManager: browserSessionManager,
      masterKeyPath,
      authActive,
      spaceAccess: {
        // Mirror the MCP/folder wiring's no-auth handling. Under no-auth the
        // browser-session API mints a synthetic 'local' req.user that carries no
        // orgIds — passing it straight to buildVisibilityWhere crashes, and even
        // normalized it cannot see the null-owner local space (non-admin, no
        // membership row). So when auth is OFF, always resolve as a synthetic
        // admin local viewer. When auth is ON, pass the real viewer through
        // (defaulting orgIds to [] defensively).
        getSpace: (spaceId, viewer) =>
          repo.getSpace(spaceId, {
            viewer: authActive
              ? (viewer && !(viewer as { orgIds?: string[] }).orgIds
                  ? ({ ...viewer, orgIds: [] } as unknown as Express.User)
                  : viewer)
              : ({ id: 'local', role: 'admin', orgIds: [] } as unknown as Express.User),
          }),
        getSpaceMemberRole: (spaceId, userId) => repo.getSpaceMemberRole(spaceId, userId),
      },
    }),
  );

  // Per-user folder REST API: list / read / write / delete with trash.
  const userFolderRoot = loadConfig().userFolderRoot ?? './data/users';

  // Spaces foundation: resolve a request's spaceId to its folder, gated on the
  // viewer's visibility of that space. Returns null when the space is not found
  // or not visible to the viewer (callers then return 404 — no silent fallback).
  // No spaceId on a request => callers use the legacy per-user folder (unchanged).
  const resolveSpaceFolderForViewer = async (
    spaceId: string,
    viewer?: Express.User,
  ): Promise<{ rootDir: string; leafId: string } | null> => {
    const space = await repo.getSpace(spaceId, { viewer });
    return space ? resolveSpaceFolder(space, userFolderRoot) : null;
  };

  app.use('/api/users/me', createUserFolderApi({ userFolderRoot, sessRepo, masterKeyPath, authActive, resolveSpaceFolder: resolveSpaceFolderForViewer }));

  // Per-user memory entries REST API (GET/PUT/DELETE).
  app.use('/api/local/memory', createMemoryApi({ dataDir: userFolderRoot, authActive, resolveSpaceFolder: resolveSpaceFolderForViewer }));

  // Per-user reflection history REST API (GET/POST).
  app.use('/api/local/reflection', createReflectionApi({ dataDir: userFolderRoot, repo, authActive }));

  // App-harness token-auth middleware (Task 5).
  // Must run BEFORE space-api so that req.user is populated before viewerOf() reads it.
  mountAppHarnessApi(app);

  // スペース（個人 / 案件）CRUD REST API。
  // NOTE: ここでグローバルな express.json() は付けない。デフォルトの ~100kb 上限が
  // upload ルートの大きい json parser より前に body を消費し、>100kb のアップロードが
  // 413 になるため。各ルートが自前の json parser を宣言する（space-api.ts 側）。
  app.use('/api/local/spaces', createSpaceApi({
    repo,
    dataRoot: userFolderRoot,
    worktreeDir: loadConfig().worktreeDir ?? './data/worktrees',
    authActive,
    getPythonPackagesConfig: () => loadConfig().pythonPackages,
  }));

  // BackendStatusRegistry singleton — probes every configured worker
  // (direct llama-server or proxy LiteLLM) at a fixed cadence so the
  // Side Info Panel's node-status widget can paint without hammering
  // the upstream per page-render. We pull workers from loadConfig() on
  // each tick so YAML edits propagate without needing a ConfigManager
  // event subscription here.
  const backendStatusRegistry = createBackendStatusRegistry({
    getWorkers: () => loadConfig().provider.workers ?? [],
    probeDirect: buildDirectProbe(),
    probeProxy: buildProxyProbe(),
  });
  backendStatusRegistry.start();

  // Worker / node (GPU) status REST API.
  app.use('/api/local/dashboard', express.json(), createDashboardApi({
    repo,
    getWorkers: () => loadConfig().provider.workers,
    authActive,
    backendStatusRegistry,
  }));

  // Phase 3c — same-process AAO Gateway mount + bridge /health fallback +
  // admin gateway status endpoint (see bridge-gateway-mount.ts; the
  // gateway gate must register BEFORE /health — order preserved inside).
  const gatewayMount: GatewayMountHandle | null = mountBridgeGateway(app, {
    repo,
    configManager: opts.configManager,
    sharedPromRegistry,
    authActive,
    listenPort: opts.listenPort,
  });

  // Wire user-folder tool deps so RunUserScript / ListUserAssets can decrypt sessions.
  setUserFolderToolDeps({
    sessRepo,
    masterKeyPath,
    userFolderRoot,
    auditLog: (action, detail, jobId) => {
      // Best-effort: never let an audit failure surface to the tool caller.
      repo.addAuditLog(jobId ?? null, action, 'tool', detail).catch(err => {
        logger.warn(`[user-folder-tool] audit log failed action=${action} err=${err}`);
      });
    },
  });

  // Wire skill tool deps so InstallSkill / InstallSkillFromDir can audit-log.
  setSkillToolDeps({
    auditLog: (action, detail, jobId) => {
      repo.addAuditLog(jobId ?? null, action, 'tool', detail).catch(err => {
        logger.warn(`[skill-tool] audit log failed action=${action} err=${err}`);
      });
    },
    userFolderRoot,
  });

  // Wire app-docs tool deps so GetMyOrchestratorState can introspect DB.
  setAppDocsDeps({ db: repo.getDb(), userFolderRoot });

  // noVNC static files + session authorizer (see novnc-mount.ts)
  mountNovncStatic(app);
  const authorizeNovncSession: import('./novnc-proxy.js').NovncSessionAuthorizer =
    buildNovncSessionAuthorizer(repo);

  return { app, browserSessionManager, authenticateUpgrade, authorizeNovncSession, sshConsole, backendStatusRegistry, workerMetrics, gatewayMount, authActive, serverConfig: serverCfg };
}

// Definition moved to auth-subsystem.ts; re-exported here so existing
// importers of bridge/server.js keep working unchanged.
export { computeEffectiveSecureCookie } from './auth-subsystem.js';

/** Heuristic: native TLS + the proxy signal (secure_cookie) likely means a
 *  reverse proxy is also terminating TLS → double-TLS misconfiguration. */
export function shouldWarnDoubleTls(tlsEnabled: boolean, secureCookie: boolean): boolean {
  return tlsEnabled && secureCookie;
}

export function finalizeServer(app: express.Application): express.Application {
  app.use((_req: Request, res: Response) => {
    res.status(404).json({ error: 'Not found' });
  });

  app.use((err: Error, _req: Request, res: Response, _next: NextFunction) => {
    // body-parser signals an exceeded json({ limit }) with type
    // 'entity.too.large'. Surface it as the 413 it is — collapsing it into a
    // generic 500 hides the actual problem ("attachment over the configured
    // limit") from API clients and the UI.
    if ((err as { type?: string }).type === 'entity.too.large') {
      logger.warn(`Request body too large: ${err.message}`);
      res.status(413).json({ error: 'Request body too large' });
      return;
    }
    logger.error(`Unhandled error: ${err.message}`);
    res.status(500).json({ error: 'Internal server error' });
  });

  return app;
}

function deriveCallbackBaseUrl(authConfig: AuthConfig | undefined): string {
  const providers = authConfig?.providers;
  if (providers) {
    for (const p of Object.values(providers) as Array<{ callbackUrl?: string } | undefined>) {
      if (!p) continue;
      const cb = p.callbackUrl;
      if (cb) {
        try {
          return new URL(cb).origin;
        } catch {
          // ignore malformed URL
        }
      }
    }
  }
  // Same precedence as the listener (PORT env > server.port > default) so the
  // derived OAuth callback origin matches the port the bridge actually binds.
  const port = resolveListenPort(process.env.PORT, loadConfig().server?.port);
  return `http://localhost:${port}`;
}

export function startCoreServer(opts: CoreServerOptions, port: number = 9876): void {
  const {
    app,
    browserSessionManager,
    authenticateUpgrade,
    authorizeNovncSession,
    sshConsole,
    backendStatusRegistry,
    workerMetrics,
    gatewayMount,
    authActive,
    serverConfig,
    // Forward the actual port to createCoreServer so the admin gateway
    // status endpoint reports the real bind port (not the PORT env
    // guess). See `listenPort` doc on CoreServerOptions.
  } = createCoreServer({ ...opts, listenPort: port });
  // Phase 3c: parking the handle here so a future status endpoint or
  // probe can read it. Currently unused at top level (the bridge
  // gateway-status admin endpoint reads via the createCoreServer return).
  void gatewayMount;
  // Phase 3b: fan the worker metrics handle out to every Worker so the
  // job lifecycle / LLM / tool counters fire. WorkerManager.setWorkerMetrics
  // is the documented hook for this — it also propagates on the next
  // rebuild so a config change doesn't blank metrics for new workers.
  if (workerMetrics && opts.workerManager) {
    opts.workerManager.setWorkerMetrics(workerMetrics);
  }
  const finalApp = finalizeServer(app);
  // Bind host: loopback unless HOST is set explicitly. The agent API includes
  // the Bash tool, so an unauthenticated instance reachable on the LAN is
  // effectively unauthenticated RCE. Bare-metal / systemd installs inherit this
  // loopback default. The Docker image sets HOST=0.0.0.0 (it must bind all
  // interfaces inside the container) and docker-compose.yml publishes the port
  // on all host interfaces by default, so a Docker deployment is LAN-reachable
  // out of the box — enabling auth/TLS before exposing it is the operator's
  // responsibility (see docs/docker.md, SECURITY.md). Restore local-only by
  // pinning the compose mapping back to "127.0.0.1:9876:9876".
  const host = process.env['HOST'] ?? '127.0.0.1';
  const isLoopbackBind = host === '127.0.0.1' || host === '::1' || host === 'localhost';
  // Use the config snapshot already resolved in createCoreServer (same port,
  // same loadConfig() call) — no second read so a hot-reload between the two
  // cannot make the cookie-secure flag disagree with the listener type.
  const tls = serverConfig.tls;
  let server: import('http').Server | import('https').Server;
  if (tls.enabled) {
    // Augment the self-signed SAN list with the redirect target host and the
    // (non-wildcard) bind host so that browsers following the HTTP→HTTPS redirect
    // always land on a hostname that is covered by the certificate.  Provided-cert
    // deployments are unaffected because resolveTlsOptions ignores selfSignedHosts
    // when cert_file/key_file are set.
    const extraSan = [
      tls.redirectHost,
      host && host !== '0.0.0.0' && host !== '::' ? host : null,
    ].filter((h): h is string => !!h);
    const tlsForResolve = extraSan.length
      ? { ...tls, selfSignedHosts: [...tls.selfSignedHosts, ...extraSan] }
      : tls;
    const resolved = resolveTlsOptions(tlsForResolve); // fatal throw on bad operator cert
    server = createHttpsServer({ cert: resolved.cert, key: resolved.key, minVersion: resolved.minVersion }, finalApp);
    server.listen(port, host, () => {
      const source = tls.certFile ? `provided(${tls.certFile})` : 'self-signed';
      const fp = new X509Certificate(resolved.cert).fingerprint256;
      logger.info(`Core server listening on https://${host}:${port} cert=${source} sha256=${fp}`);
      if (shouldWarnDoubleTls(true, !!opts.authConfig?.secureCookie)) {
        logger.warn(
          `[security] server.tls.enabled is ON while auth.secure_cookie is also ON (reverse-proxy signal). ` +
            `If a TLS-terminating proxy is in front of this app, set server.tls.enabled: false to avoid double TLS.`,
        );
      }
    });
    if (tls.httpRedirect) {
      const isWildcardBind = host === '0.0.0.0' || host === '::';
      // With a wildcard bind and no explicit redirect_host there is no single
      // correct host to pin (the bind address itself, 0.0.0.0, is not browser-
      // reachable). Derive the redirect target from the client's own Host header
      // instead — a scheme upgrade to the host they already used. 'localhost' is
      // only the last-resort pin for a request with no usable Host header.
      const preferRequestHost = tls.redirectHost == null && isWildcardBind;
      if (preferRequestHost) {
        logger.info(
          `[server] HTTP->HTTPS redirect derives its host from the request Host header ` +
            `(wildcard bind ${host}, no server.tls.redirect_host set). Set redirect_host to pin it explicitly.`,
        );
      }
      const pinnedHost = tls.redirectHost ?? (isWildcardBind || isLoopbackBind ? 'localhost' : host);
      // One listener per configured redirect port (e.g. 80 plus a high port).
      for (const rport of tls.httpRedirectPorts) {
        const redirector = createHttpRedirectServer({ httpsPort: port, pinnedHost, preferRequestHost });
        redirector.on('error', (e) =>
          logger.warn(`[server] HTTP redirect listener error on :${rport}: ${(e as Error).message}`),
        );
        redirector.listen(rport, host, () =>
          logger.info(`HTTP->HTTPS redirect listening on http://${host}:${rport}`),
        );
      }
    }
  } else {
    server = finalApp.listen(port, host, () => {
      logger.info(`Core server listening on ${host}:${port}`);
      if (!isLoopbackBind && !authActive) {
        logger.warn(
          `[security] Listening on ${host} with authentication DISABLED. The agent API ` +
            `(including the Bash tool) is reachable by anyone who can reach this host — ` +
            `this is effectively unauthenticated remote code execution. Enable auth in ` +
            `config.yaml (auth.local or an OAuth provider) before exposing a non-loopback ` +
            `interface, or unset HOST to bind 127.0.0.1.`,
        );
      }
    });
  }

  // 起動と同時に CAPTCHA Pool の idle GC を回す (task session を 5 分アイドルで GC)
  if (browserSessionManager) browserSessionManager.startIdleGc();

  // User folder の trash を定期的に sweep (デフォルト 30 日)
  {
    const cfg = loadConfig();
    const userFolderRoot = cfg.userFolderRoot ?? './data/users';
    const retentionDays = cfg.tools?.trashRetentionDays ?? 30;
    startTrashCleanup({ userFolderRoot, retentionDays });

    // Reflection snapshot retention sweep (daily, same schedule as trash)
    startReflectionRetentionSweep({
      dataDir: userFolderRoot,
      config: {
        snapshotRetentionDays: cfg.reflection.snapshotRetentionDays,
        snapshotMaxBytesPerUser: cfg.reflection.snapshotMaxBytesPerUser,
      },
    });
  }

  setupNovncWebSocketProxy(server, () => browserSessionManager, authenticateUpgrade, authorizeNovncSession);

  // Phase 5 (SSH Console): attach the WS upgrade handler now that we
  // have the http.Server. attachConsoleWs only handles upgrades that
  // match /api/local/tasks/:id/console/ws; all other upgrades fall
  // through to noVNC (or are dropped).
  if (sshConsole) {
    attachConsoleWs(server, sshConsole);

    // Graceful shutdown: close all live console sessions so we don't
    // leak channels / DEK references. The registry tears down the
    // sweep timer too. Errors are caught inside the shutdown driver
    // (see ./shutdown.ts) — never block the signal.
    registerShutdownHook('ssh-console', async () => {
      await sshConsole.registry.shutdown();
    });
  }

  // Tear down the BackendStatusRegistry's polling timer on shutdown so
  // tests / restart loops don't leak handles. stop() is async — it
  // aborts the in-flight probe cycle (via AbortController) and awaits
  // settlement so pending fetches don't outlive the signal. Without
  // this, SIGTERM could block process exit for up to ~3s per probe.
  registerShutdownHook('backend-status-registry', async () => {
    await backendStatusRegistry.stop();
  });

  // Install SIGTERM / SIGINT listeners exactly once. All previous
  // subsystem-local handler pairs (one per Phase) have been folded
  // into the single registry above — this avoids hitting Node's
  // default MaxListeners cap (10) as Phase D adds more cleanup paths.
  installSignalHandlers();
}
