import './servers/bootstrap';
import type { ServerWebSocket } from 'bun';
import { serve } from 'bun';
import { join } from 'node:path';
import { honoServer, PROTECTED_API_PREFIXES, UNPROTECTED_API_PREFIXES } from './servers/hono';
import { assertCapabilityTotality } from './servers/capabilities/totality';
import { refreshPluginMounts, snapshotPlugins } from './servers/plugins/mount';
import { generatePluginsModule, rebuildFrontend, BUILD_DIR, SHELL_FILE } from './servers/plugins/generate';
import { assertInstallLayout } from './servers/data-path';
import { PORT } from './servers/officer-url.mjs';
import { assertSecretsClosed } from './servers/os-user';
import { resolveHomeDir } from './servers/user-home';
import { resolveAuthToken } from './servers/auth-token';
import { isWsProviderAllowed } from './servers/capabilities/authorize';
import { isTokenBlacklisted, getUserById } from 'officerdb';
import { terminalWebsocket } from './servers/api/terminal/websocket';
import { chatWebsocket } from './servers/api/chat/websocket';
import { taskRunnerWebsocket } from './servers/api/tasks/task-executor';
import { pipelineWebsocket } from './servers/api/tasks/pipeline-executor';
// import { desktopWebsocket } from './servers/api/desktop/websocket';
// import { vaultWebsocket, upgradeVaultWs } from './servers/api/vault/websocket';
import officerWeb from './apps/officer-web/index.gen.html';
// import { startBrowserRelay } from './servers/api/browser/relay';  // switched off — see below
import { registerSidecar, unregisterSidecar, handleSidecarMessage } from './servers/sidecar-registry';
import './servers/api/chat/opencode/sidecar-server'; // subscribe to the opencode sidecar's port report
import type { SidecarRegistration } from './servers/sidecar/registration-protocol';
import { toShellUsername } from './servers/data-path';

// Build static file routes from public/
const publicRoutes: Record<string, (req: Request) => Response> = {};
for await (const file of new Bun.Glob('**').scan({ cwd: './public' })) {
  const bunFile = Bun.file(`./public/${file}`);
  publicRoutes[`/${file}`] = () => new Response(bunFile);
}

type WSData = {
  userId: number;
  email: string;
  username: string;
  provider: 'terminal' | 'chat' | 'task-runner' | 'pipeline' | 'desktop' | 'vault' | 'sidecar';
  sessionId?: string;
  cwd?: string;
  command?: string;
  cols?: number;
  rows?: number;
  search?: string; // raw query string, for providers that relay it to a sidecar
};

// Sidecar registration WebSocket handler
const sidecarConnections = new Map<ServerWebSocket<WSData>, string>(); // ws → sidecar ID

const sidecarWebsocket = {
  open(_ws: ServerWebSocket<WSData>) {
    // Wait for registration message
  },
  message(ws: ServerWebSocket<WSData>, raw: string | Buffer) {
    try {
      const data = typeof raw === 'string' ? raw : raw.toString();
      const msg = JSON.parse(data);

      if (msg.type === 'register') {
        const id = registerSidecar(ws, msg as SidecarRegistration);
        sidecarConnections.set(ws, id);
        ws.send(JSON.stringify({ type: 'registered', id }));
        return;
      }

      // Handle queue commands from sidecars (e.g., email sidecar enqueuing jobs)
      if (typeof msg.type === 'string' && msg.type.startsWith('queue:') && msg.id) {
        handleSidecarQueueCommand(ws, msg);
        return;
      }

      const id = sidecarConnections.get(ws);
      if (id) {
        handleSidecarMessage(id, msg);
      }
    } catch {
      // skip malformed messages
    }
  },
  close(ws: ServerWebSocket<WSData>) {
    const id = sidecarConnections.get(ws);
    if (id) {
      unregisterSidecar(id);
      sidecarConnections.delete(ws);
    }
  },
  drain() {},
};

// Handle queue commands from sidecars (e.g., email sidecar enqueuing jobs)
async function handleSidecarQueueCommand(ws: ServerWebSocket<WSData>, msg: Record<string, unknown>) {
  const id = msg.id as string;
  try {
    switch (msg.type) {
      case 'queue:enqueue': {
        const job = await queueEnqueue(msg.params as import('./servers/queue/types').EnqueueParams);
        ws.send(JSON.stringify({ type: 'queue:enqueued', id, job }));
        break;
      }
      case 'queue:cancel': {
        const job = await queueCancel(msg.jobId as string);
        ws.send(JSON.stringify({ type: 'queue:cancelled', id, job }));
        break;
      }
      case 'queue:list': {
        const jobs = await queueList();
        ws.send(JSON.stringify({ type: 'queue:list', id, jobs }));
        break;
      }
      case 'queue:get': {
        const job = await queueGet(msg.jobId as string);
        ws.send(JSON.stringify({ type: 'queue:get', id, job }));
        break;
      }
      default:
        ws.send(JSON.stringify({ type: 'queue:error', id, error: `Unknown queue command: ${msg.type}` }));
    }
  } catch (err) {
    ws.send(JSON.stringify({ type: 'queue:error', id, error: err instanceof Error ? err.message : String(err) }));
  }
}

const handlers: Record<string, any> = {
  terminal: terminalWebsocket,
  chat: chatWebsocket,
  'task-runner': taskRunnerWebsocket,
  pipeline: pipelineWebsocket,
  // desktop: desktopWebsocket,
  // vault: vaultWebsocket,
  sidecar: sidecarWebsocket,
};

// Both doors into the platform, checked against the capability registry before either opens.
//
// This throws rather than warns, and it throws HERE — before serve() — so a surface nobody has gated
// cannot be reached even once. The HTTP half comes from hono.ts's own mount table and the socket half
// from the map directly above, so neither list can be a stale copy of the thing it describes.
assertCapabilityTotality({
  apiPrefixes: [...PROTECTED_API_PREFIXES, ...UNPROTECTED_API_PREFIXES],
  wsProviders: Object.keys(handlers),
});

// That the working directory is the repo, because every path derives from its parent. First of the three,
// since a wrong answer here makes the other two check the wrong files.
assertInstallLayout();

// And, when members have real Linux accounts, that they cannot read the credentials that would make those
// accounts pointless. Also before serve(), also throws: a shell handed out next to a world-readable
// JWT_SECRET is worse than no isolation, because the model looks intact.
await assertSecretsClosed(process.cwd());

// Mount whatever is installed, BEFORE serve().
//
// Without this a restart silently unmounts every plugin: `honoServer` is built with none at import,
// because discovery reads the disk and the database and neither can be awaited at module scope. The
// install verbs each rebuild, so it looked correct in testing — right up until the first restart, which
// is exactly the kind of bug that ships.
//
// Before `serve()` rather than after, so there is no window in which an installed plugin's route 404s.
// Failure is logged and survived: a plugin that will not load must not stop the platform from starting,
// and `refreshPluginMounts` already isolates one bad plugin from the rest.
try {
  // Generate first, then build, then mount. The generated module is what the bundler reads, so writing it
  // after the build would produce a bundle describing the previous plugin set.
  const { states } = await snapshotPlugins();
  generatePluginsModule(states);

  const built = await rebuildFrontend();
  if (built.ok) console.log(`[plugins] frontend built in ${built.ms}ms (${built.outputs} outputs)`);
  else console.error('[plugins] frontend build FAILED —', built.error);

  // `skipFrontend`, because the build above already used the same snapshot. Rebuilding here would cost a
  // second of start-up to produce a byte-identical bundle.
  const { mounted, broken, rejected } = await refreshPluginMounts({ skipFrontend: true });
  if (mounted.length) console.log(`[plugins] mounted ${mounted.join(', ')}`);
  if (broken.length) console.error(`[plugins] unreadable: ${broken.join(', ')}`);
  if (rejected.length) console.error(`[plugins] permission keys refused: ${rejected.join(', ')}`);
} catch (err) {
  console.error('[plugins] none mounted —', err instanceof Error ? err.message : err);
}

// ── Serving the SPA ──
//
// In PRODUCTION the bundle comes from `build/`, rebuilt whenever a plugin is installed. That is what makes
// a plugin's frontend appear without restarting the process: the HTML import below is bundled once when
// the module graph loads and can never change afterwards.
//
// In DEVELOPMENT the HTML import is kept, because it is what gives HMR — and `bun --watch` restarts on
// every source change anyway, so nothing is gained by building. The two paths differ only in where the
// bytes come from; the generated `Plugins.gen.tsx` is the same file in both.
const serveBuilt = async (req: Request): Promise<Response> => {
  const path = new URL(req.url).pathname;

  // An asset if it exists, the shell otherwise. A client-side route like `/offscale/servers` is not a file
  // and must return index.html, which is what makes deep links work at all.
  // A request that LOOKS like an asset and is not one must 404, never fall through to the shell. Serving
  // HTML with a JS content-type produces "Unexpected token '<'" and a blank page — a failure mode that
  // reads as a broken build rather than a missing file.
  if (/\.(js|css|map|png|svg|ico|webmanifest|woff2?)$/.test(path)) {
    const file = Bun.file(join(BUILD_DIR, path.slice(1)));
    if (!(await file.exists())) return new Response('Not found', { status: 404 });
    return new Response(file, { headers: { 'cache-control': 'public, max-age=31536000, immutable' } });
  }

  const asset = Bun.file(join(BUILD_DIR, path === '/' ? SHELL_FILE : path.slice(1)));
  if (path !== '/' && (await asset.exists())) {
    // Content-hashed, so the name changes whenever the bytes do and this can be cached hard. The shell
    // below is the opposite case and must not be.
    return new Response(asset, { headers: { 'cache-control': 'public, max-age=31536000, immutable' } });
  }

  const shell = Bun.file(join(BUILD_DIR, SHELL_FILE));
  if (!(await shell.exists())) {
    // The build failed or never ran. Say so rather than 404ing, because a blank page here looks like a
    // broken deploy and this is recoverable by restarting or reinstalling the plugin that broke it.
    return new Response('The interface has not been built. Check the server log for a build failure.', {
      status: 503,
      headers: { 'content-type': 'text/plain' },
    });
  }
  // NEVER cache the shell.
  //
  // It names the hashed chunks, so a cached shell is a browser pinned to a build that no longer exists on
  // disk — every asset it asks for 404s, or worse it renders an old app against a current API. Served with
  // no cache headers at all until now, which leaves it to the browser's heuristics: a plugin could be
  // installed, the bundle rebuilt, and the tab keep running the previous one with no way to tell.
  return new Response(shell, {
    headers: {
      'content-type': 'text/html',
      'cache-control': 'no-cache, no-store, must-revalidate',
    },
  });
};

const isProduction = process.env.NODE_ENV === 'production';
const spaRoutes = isProduction ? { '/': serveBuilt, '/*': serveBuilt } : { '/': officerWeb, '/*': officerWeb };

// Said out loud, because "is it serving the build I just made, or the one bundled at import?" is otherwise
// answerable only by hiding the shell and watching for a 503 — which is how it was answered once.
//
// It matters because the two behave differently in exactly the case that matters: the HTML import is
// bundled when the module graph loads and can never change, so installing a plugin would rebuild `build/`
// and serve something else entirely.
console.log(
  isProduction
    ? `[web] serving the built SPA from ${BUILD_DIR} — a plugin install rebuilds it`
    : '[web] serving via the HTML import (dev) — HMR is live, and `build/` is not used',
);

async function upgradeWs(
  req: Request,
  server: any,
  provider: 'terminal' | 'chat' | 'task-runner' | 'pipeline' | 'desktop',
) {
  const token = new URL(req.url).searchParams.get('token');
  if (!token) return new Response('Unauthorized', { status: 401 });

  try {
    // Same resolver as the two HTTP doors, so a key that works against /api works here too — a mobile app
    // holding one gets the same doors as a browser session, and a socket that only understood JWTs would
    // have made "signed in" and "can open this" two different questions. `jti` is absent on a key, so the
    // blacklist below simply does not apply to one; its revocation is a column, checked in the lookup.
    const user = await resolveAuthToken(token);
    if (!user) return new Response('Unauthorized', { status: 401 });

    if (user.jti) {
      if (await isTokenBlacklisted(user.jti)) return new Response('Unauthorized', { status: 401 });
    }

    // The capability backstop, applied to sockets. Everything above this line AUTHENTICATES — it proves
    // who is calling and never asks what they may reach. That is why a Member with a valid token could
    // open a terminal here in the same minute it was 403'd on GET /api/tasks.
    //
    // This resolves against the same registry as the HTTP door rather than a parallel list, which is the
    // whole point: the two doors cannot disagree about what a role holds, because there is only one
    // declaration to read. `terminal`, `chat`, `task-runner`, `pipeline` and `desktop` are refused here
    // by being `execution` capabilities, not by being absent from an array someone has to maintain.
    if (!(await isWsProviderAllowed(user.id, provider))) {
      return new Response('Forbidden', { status: 403 });
    }

    const url = new URL(req.url);
    const sessionId = url.searchParams.get('sessionId') ?? undefined;
    const cwd = url.searchParams.get('cwd') ?? undefined;
    const command = url.searchParams.get('command') ?? undefined;
    const cols = url.searchParams.get('cols') ? Number(url.searchParams.get('cols')) : undefined;
    const rows = url.searchParams.get('rows') ? Number(url.searchParams.get('rows')) : undefined;

    // ── Whose shell is this ──
    //
    // The terminal bridge forwards this query string to the pty sidecar untouched, and the sidecar starts a
    // shell from what it finds there. So `osUser` and `home` are resolved HERE, from the authenticated
    // account, and any values the browser sent are deleted first. Trusting the client for either would let a
    // member ask for the owner's uid in a query parameter.
    //
    // Absent for the owner: no `osUser` means the sidecar runs the shell as itself, which is the behaviour
    // this has always had.
    url.searchParams.delete('osUser');
    url.searchParams.delete('home');

    // The chat socket's owner-only refusal was removed on 2026-08-12, with `api/chat/chat.ts`'s in the same
    // commit — they were always one guard in two places. A member's turn now runs as their own Linux account
    // with their own credential and their own transcripts; the capability check above is what gates it.

    if (provider === 'terminal') {
      const resolved = await resolveHomeDir(user.id);
      if (!resolved.ok) return new Response('Forbidden', { status: 403 });
      if (!resolved.isOwner) {
        const dbUser = await getUserById(user.id);
        // A confined capability is only granted to an account with an OS user, so this should not happen —
        // and if it ever does, refusing beats opening the owner's shell.
        if (!dbUser?.osUser) return new Response('Forbidden', { status: 403 });
        url.searchParams.set('osUser', dbUser.osUser);
        url.searchParams.set('home', resolved.home);
      }
    }

    const ok = server.upgrade(req, {
      data: {
        userId: user.id,
        email: user.email,
        username: toShellUsername(user.username ?? '', user.email),
        provider,
        sessionId,
        cwd,
        command,
        cols,
        rows,
        search: url.search,
      },
    });
    if (!ok) return new Response('Upgrade failed', { status: 500 });
  } catch {
    return new Response('Unauthorized', { status: 401 });
  }
}

const server = serve({
  port: PORT,
  idleTimeout: 60,
  maxRequestBodySize: 1024 * 1024 * 1024 * 50, // 50 GB
  routes: {
    ...publicRoutes,
    '/novnc/*': (req) => {
      const file = Bun.file(`public${new URL(req.url).pathname}`);
      return new Response(file);
    },
    '/vendor/*': (req) => {
      const file = Bun.file(`public${new URL(req.url).pathname}`);
      return new Response(file);
    },
    // Icons and assets belonging to installed sidecars, copied to public/plugins/<id>/ by the installer.
    //
    // Served by this dynamic route rather than by `publicRoutes` above, which is a snapshot taken by
    // globbing ./public at BOOT. A plugin installed while the server is running would not be in that map,
    // so its icon would 404 until the next restart — and "install it, then restart the server to see the
    // icon" is not an install.
    '/plugins/*': async (req) => {
      const file = Bun.file(`public${new URL(req.url).pathname}`);
      // 404 rather than letting a missing file surface as a 500. An icon that has not been published
      // yet is an ordinary state — the plugin is not installed — and a 500 would put a red line in the
      // log for every dock render on a fresh machine.
      if (!(await file.exists())) return new Response('Not found', { status: 404 });
      return new Response(file);
    },
    // Vaultwarden notifications hub: upgrade the WebSocket here (proxied to upstream by vaultWebsocket);
    // everything else on this path (SignalR long-poll negotiate/poll) falls through to the HTTP proxy.
    // '/api/vault/notifications/*': (req, server) => {
    //   if (req.headers.get('upgrade') === 'websocket') return upgradeVaultWs(req, server);
    //   return honoServer.fetch(req, server);
    // },
    '/api/sidecar/register': (req: Request, server: any) => {
      const ok = server.upgrade(req, {
        data: { provider: 'sidecar', userId: 0, email: '', username: '' },
      });
      if (!ok) return new Response('Upgrade failed', { status: 500 });
    },
    '/api/tasks/run/ws': (req, server) => upgradeWs(req, server, 'task-runner'),
    '/api/tasks/pipeline/ws': (req, server) => upgradeWs(req, server, 'pipeline'),
    '/api/terminal/ws': (req, server) => upgradeWs(req, server, 'terminal'),
    '/api/chat/ws': (req, server) => upgradeWs(req, server, 'chat'),
    // '/api/desktop/ws': (req, server) => upgradeWs(req, server, 'desktop'),
    // CalDAV/CardDAV. These live OUTSIDE /api because DAV clients are given a bare domain and probe
    // fixed, spec-defined paths — `/.well-known/caldav` unauthenticated, before they hold any
    // credential at all. They need naming explicitly here or the `/*` SPA fallback below swallows them
    // and the phone gets an HTML page where it expected a redirect.
    // '/.well-known/caldav': honoServer.fetch,
    // '/.well-known/carddav': honoServer.fetch,
    // '/dav': honoServer.fetch,
    // '/dav/*': honoServer.fetch,
    // Vaultwarden for third-party Bitwarden clients, unauthenticated at Officer's layer. Needs its own
    // entry for the same reason /dav does: only the paths listed here reach hono, and anything else
    // falls through to the SPA — which answers 200 with the React shell, so a missing line here looks
    // like a working endpoint returning nonsense rather than a 404.
    // '/vaultwarden/*': honoServer.fetch,
    // The same proxy at the root, so the extension needs only the bare Officer URL. These four prefixes
    // are Vaultwarden's alone — nothing in Officer answers on them — so routing them here costs nothing.
    // `/api/*` already reaches hono below, where a Bitwarden client header diverts it.
    // '/identity/*': honoServer.fetch,
    // '/notifications/*': honoServer.fetch,
    // '/icons/*': honoServer.fetch,
    // '/events/*': honoServer.fetch,
    ...spaRoutes,
    '/api': honoServer.fetch,
    // A CLOSURE, deliberately, and not the bound `honoServer.fetch`.
    //
    // Installing a plugin swaps the whole Hono app (`rebuildHonoApp` — Hono cannot add routes to a live
    // app, and cannot remove one at all). The bound method would capture whichever app existed when
    // `serve()` ran, so every rebuild after boot would be invisible and an install would silently do
    // nothing. Reading `honoServer` per request is what makes the reassignment the swap.
    '/api/*': (req: Request, server: unknown) => honoServer.fetch(req, server),
  },

  websocket: {
    open(ws) {
      const { provider } = (ws as unknown as ServerWebSocket<WSData>).data;
      handlers[provider]!.open(ws as any);
    },
    message(ws, raw) {
      const { provider } = (ws as unknown as ServerWebSocket<WSData>).data;
      handlers[provider]!.message(ws as any, raw);
    },
    close(ws) {
      const { provider } = (ws as unknown as ServerWebSocket<WSData>).data;
      handlers[provider]!.close(ws as any);
    },
    drain() {},
  },

  development: process.env.NODE_ENV !== 'production' && {
    hmr: true,
  },
});

console.log(`🚀 Server running at ${server.url}`);

// ── The browser relay is not started. Deliberate, 2026-08-13. ──
//
// The Chrome extension, its CDP bridge (api/browser/) and the /browser screen all remain on disk: this
// is going to be extracted into a plugin, and deleting it would mean writing it again. What is switched
// off is the second listener and the /api/browser mount (see hono.ts) — the platform serves one port.
//
// It used to read BROWSER_RELAY_PORT, default 18792. The name is recorded here because there is nothing
// left to grep for, and whoever does the extraction needs to know what it was called.
//
// ── Read this before turning it back on ──
//
// The port is not only configuration. relay-auth.ts derives each extension's token as
// HMAC(JWT_SECRET, `officer-browser-relay-v1:${port}:${userId}:${salt}`), so the port is an INPUT TO A
// CREDENTIAL. Bringing the relay back on a different number silently invalidates every paired browser,
// and the extension reports it as "Relay not reachable" — which SETUP.md attributes to a wrong address,
// port or token. Re-pairing means re-copying from Settings → Browser Relay.
//
// Whatever it comes back as, it cannot be a kernel-assigned port:0 the way the other sidecars are. The
// extension is configured by hand and stores the value, so a port that changes each restart breaks the
// pairing every restart. It has to be predictable — PORT + 2 is the shape the Anthropic proxy already
// uses for the same reason.
//
// try {
//   await startBrowserRelay(BROWSER_RELAY_PORT);
//   console.log(`[browser-relay] listening on port ${BROWSER_RELAY_PORT}`);
// } catch (err) {
//   console.error('[browser-relay] failed to start:', err instanceof Error ? err.message : err);
// }

// Initialize queue engine in API server process
import {
  initQueue,
  enqueueJob as queueEnqueue,
  cancelJob as queueCancel,
  listAllJobs as queueList,
  readJob as queueGet,
} from './servers/queue/init';
import { cleanupOnStartup } from './servers/api/tasks/pipeline-job-manager';
import { waitForDatabase } from 'officerdb';

// ── Startup work that needs the database, and waits for it ──
//
// Both of these query Postgres, and both used to be fired as bare promises with a `.catch()` that
// logged. That is fine when the database is up and silently wrong when it is not — which is precisely
// what a reboot looks like, with pm2's resurrect racing Docker starting the Postgres container.
//
// `cleanupOnStartup` is the one that matters: it marks jobs interrupted by the previous shutdown and
// promotes the queued backlog. Fail it once and those jobs stay marked running forever, because the
// only thing that would have corrected them has already run. Nothing retries and nothing complains
// again — the log line scrolls past during boot and the jobs are simply stuck.
//
// Deliberately NOT awaited before serve(): the HTTP listener is already up by here, and holding it
// closed for a minute would turn a database that is thirty seconds late into a reverse proxy serving
// connection-refused instead of a page. Requests that need the database fail honestly in the meantime.
void (async () => {
  if (!(await waitForDatabase())) {
    console.error('[startup] skipping queue init and pipeline cleanup — the database never answered');
    return;
  }
  await initQueue().catch((err) => console.error('[queue] failed to initialize:', err));
  await cleanupOnStartup().catch((err) => console.error('[pipeline-jobs] startup cleanup failed:', err));
})();

// PulseAudio and the `virtual_out` sink used to be set up here, at every boot of a process that has no
// audio responsibilities. Every trace of cliamp left on 2026-08-15 — the relay, the two socket routes and
// the provider names — and now lives in `plugins/music/cliamp/`, parked rather than working. Bringing it
// back means a plugin owning a websocket, which is a platform gap, not a music one.

// Pi check/install is handled by bootstrap.ts (imported above)
