import type { SidecarCommand, SidecarEvent } from '../protocol';
import { createSidecarConnector } from '../connect';
import { dispatch, configuredChannels } from './dispatch';
import { handleDeviceRoute } from './devices';
import { resolveNotifyUser } from './resolve-user';
import { closeApnsSessions } from './apns';
import type { Notification, NotifyType } from './types';
import { API_URL } from '../../officer-url.mjs';

// The officer-notify sidecar. The one place anything leaves this machine to tell the owner something.
//
// It exists as a sidecar rather than as platform code because the producers are spread out — the queue,
// the email sidecar, the agent sidecar — and a platform-owned notifier would force every sidecar to call
// BACK into the platform. That is the inversion just removed from email. Here, anything POSTs over
// loopback and the direction of every arrow stays the same.
//
// ─────────────────────────────────────────────────────────────────────────────────────────────────
// HTTP CONTRACT — the platform strips its /api/notify mount prefix before forwarding.
//
//   GET  /_health                    liveness, plus which channels are configured
//   POST /_officer/notify            send one. Body: { type, userId, id?, count?, ok?, appSlug? }
//   POST /_officer/devices           register/refresh a device (idempotent; call every launch)
//   DELETE /_officer/devices/:token  deregister, on sign-out
//   GET  /_officer/devices           the caller's registered devices
//
// The notify body is deliberately narrow: a category and an id, never content. See ./text.ts for why
// the visible string is composed here rather than sent by the producer.
// ─────────────────────────────────────────────────────────────────────────────────────────────────

const VALID_TYPES: NotifyType[] = ['job', 'mail', 'agent', 'download', 'test'];

/** 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);

    if (url.pathname === '/_health') {
      return Response.json({ ok: true, channels: configuredChannels() });
    }

    if (url.pathname === '/_officer/notify' && req.method === 'POST') {
      let body: Partial<Notification>;
      try {
        body = (await req.json()) as Partial<Notification>;
      } catch {
        return Response.json({ error: 'invalid json' }, { status: 400 });
      }

      if (!body.type || !VALID_TYPES.includes(body.type)) {
        return Response.json({ error: `type must be one of ${VALID_TYPES.join(', ')}` }, { status: 400 });
      }
      // The header wins over the body wherever it is present — see ./resolve-user.ts for why that
      // ordering is the whole access control on this route.
      const userId = resolveNotifyUser({ header: req.headers.get('X-Officer-User'), bodyUserId: body.userId });
      if (userId === null) {
        return Response.json({ error: 'userId is required (body or X-Officer-User)' }, { status: 400 });
      }

      const results = await dispatch({ ...body, userId } as Notification);
      return Response.json({ ok: true, results });
    }

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

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

console.log(
  `[notify] listening on http://127.0.0.1:${server.port} — channels: ${configuredChannels().join(', ') || 'none configured'}`,
);

// ── Connect to the API server ──

const connection = createSidecarConnector({
  apiUrl: `${API_URL}/api/sidecar/register`,
  name: 'notify',
  capabilities: ['notify'],
  onCommand(cmd, reply) {
    const c = cmd as SidecarCommand;
    if (c.type === 'ping') return reply({ type: 'pong', id: c.id } as SidecarEvent);
    reply({ type: 'error', id: (c as { id?: string }).id, error: `Unknown command type: ${c.type}` } as SidecarEvent);
  },
  onConnected() {
    // Re-announced on every reconnect: the platform forgets the port when the socket drops, and this
    // listener outlives an officer restart.
    connection.send({ type: 'notify:server', port: server.port } as SidecarEvent);
  },
});

function shutdown(signal: string) {
  console.log(`[notify] ${signal} received, shutting down...`);
  closeApnsSessions();
  connection.destroy();
  process.exit(0);
}
process.on('SIGINT', () => shutdown('SIGINT'));
process.on('SIGTERM', () => shutdown('SIGTERM'));
