import type { SidecarCommand, SidecarEvent } from '@@/sidecar/protocol';
import { createSidecarConnector } from '@@/sidecar/connect';
import { handleOfficerRoute } from './routes';
import { MIN_VERSION_LABEL } from './version';
import { API_URL } from '@@/officer-url.mjs';

// The officer-headscale sidecar. Owns the whole Headscale contract for Officer: the registered servers and
// their admin API keys, the >=0.29 version floor, and every multi-call composition the UI needs. The platform
// API is a thin auth-gated forwarder (src/servers/api/headscale/router.ts) holding no Headscale credentials.
//
// Officer manages MANY Headscale servers, not one. The owner registers each with a URL and an API key
// generated on that server, and switches between them; one is active at a time. So configuration lives in
// Postgres (headscale_servers, keys encrypted at rest), NOT in env vars — this sidecar deliberately reads
// neither HEADSCALE_URL nor HEADSCALE_API_KEY, so a registered server can never be shadowed by host env.
// Device enrollment used to be the exception, minting keys in the platform from those two vars plus
// HEADSCALE_USER; it moved here (enroll.ts) and now acts on the active server like everything else.
//
// ─────────────────────────────────────────────────────────────────────────────────────────────────
// HTTP CONTRACT — the platform strips its /api/headscale mount prefix before forwarding.
//
//   GET /_health                          ours. Sidecar liveness only. Per-server reachability is a
//                                         different question and needs an owner, so it lives below.
//   GET    /_officer/servers              registered servers (never includes API keys)
//   POST   /_officer/servers              register {name?,url,apiKey} — validated before it is saved
//   PATCH  /_officer/servers/:id          edit; re-validated when url or apiKey changes
//   DELETE /_officer/servers/:id          deregister; promotes the newest survivor if it was active
//   POST   /_officer/servers/:id/activate switch the active server
//   GET    /_officer/servers/:id/health   probe: reachable? version? key still accepted?
//
// Everything below acts on the ACTIVE server. 409 when none is selected — see active.ts.
//
//   GET    /_officer/nodes                nodes, normalized; ?user=<username> filters
//   GET    /_officer/nodes/:id            one node
//   DELETE /_officer/nodes/:id            remove it from the tailnet
//   POST   /_officer/nodes/:id/rename     {name}
//   POST   /_officer/nodes/:id/tags       {tags} — 'tag:' prefix added if missing
//   POST   /_officer/nodes/:id/routes     {routes} whole set, or {route,approved} single toggle (RMW here)
//   POST   /_officer/nodes/:id/expire     expire its key, forcing re-auth (not a delete)
//   GET    /_officer/users                users, each with a node count the admin API doesn't provide
//   POST   /_officer/users                {name, displayName?, email?}
//   POST   /_officer/users/:id/rename     {name}
//   DELETE /_officer/users/:id            refused upstream while the user still owns nodes
//   GET    /_officer/keys                 pre-auth keys, secrets masked, with a derived status
//   POST   /_officer/keys                 {userId, reusable?, ephemeral?, expirationDays?, aclTags?}
//                                         → the ONLY response carrying the real secret
//   POST   /_officer/keys/:id/expire      expire without deleting
//   DELETE /_officer/keys/:id             delete outright
//   POST   /_officer/enroll               {userId?} → {controlUrl, authKey} — a single-use 10-minute key
//                                         for a joining device. userId is only required when the server
//                                         has more than one user.
//                                         NO CALLER since 2026-08-14: its only door was /api/vpn/enroll,
//                                         which is deleted. Kept because it is the handler a route under
//                                         /api/offscale would reuse, and because `/enroll/invites` — which
//                                         IS live — dispatches through the same function.
//   anything else                         404
//
// There is deliberately NO transparent /api/v1/* passthrough. Headscale's REST shape changed repeatedly
// below 0.29 and its ids are uint64-as-JSON-string, so proxying raw would push all of that into the browser
// — the mistake the Soulseek panels made with 37 raw upstream calls. Every quirk is absorbed here.
// ─────────────────────────────────────────────────────────────────────────────────────────────────

/** Grab an ephemeral free port by briefly binding one and releasing it. */
function getFreePort(): number {
  const probe = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: () => new Response('') });
  const p = probe.port;
  probe.stop(true);
  if (p == null) throw new Error('failed to acquire a free port');
  return p;
}

const port = getFreePort();

const server = Bun.serve({
  port,
  hostname: '127.0.0.1',
  async fetch(req) {
    const url = new URL(req.url);

    // Liveness, not upstream health: with many registered servers there is no single upstream to probe, and
    // choosing one would need an authenticated owner. See /_officer/servers/:id/health for that.
    if (url.pathname === '/_health') {
      return Response.json({ ok: true, minHeadscaleVersion: MIN_VERSION_LABEL });
    }

    if (url.pathname.startsWith('/_officer/')) {
      try {
        const res = await handleOfficerRoute(req, url);
        return res ?? new Response('not found', { status: 404 });
      } catch (err) {
        console.error(`[headscale] ${req.method} ${url.pathname} failed`, err);
        return Response.json({ error: 'internal error' }, { status: 500 });
      }
    }

    return new Response('not found', { status: 404 });
  },
});

console.log(`[headscale] listening on 127.0.0.1:${port} (Headscale >=${MIN_VERSION_LABEL})`);

// ── Command handlers ──

type ReplyFn = (msg: SidecarEvent) => void;

function handleCommand(cmd: SidecarCommand, reply: ReplyFn) {
  switch (cmd.type) {
    case 'ping':
      reply({ type: 'pong', id: cmd.id });
      break;
    default:
      reply({
        type: 'error',
        id: (cmd as SidecarCommand).id,
        error: `Unknown command type: ${(cmd as Record<string, unknown>).type}`,
      });
  }
}

// ── Connect to API server ──

const connection = createSidecarConnector({
  apiUrl: `${API_URL}/api/sidecar/register`,
  name: 'headscale',
  capabilities: ['headscale'],
  onCommand(cmd, reply) {
    handleCommand(cmd as SidecarCommand, reply as ReplyFn);
  },
  onConnected() {
    // Tell the API where we're listening, so it can forward /api/headscale/* here.
    connection.send({ type: 'headscale:server', port });
    console.log(`[headscale] reported port ${port} to API`);
  },
});

// ── Graceful shutdown ──

function shutdown(signal: string) {
  console.log(`[headscale] ${signal} received, shutting down...`);
  try {
    server.stop(true);
  } catch {
    /* already stopped */
  }
  connection.destroy();
  process.exit(0);
}

process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
