import type { SidecarCommand, SidecarEvent } from '../protocol';
import { createSidecarConnector } from '../connect';
import { startRadicale, davPaths } from './radicale';
import { listCollections, listEvents, listContacts } from './collections';
import { DATA_PATH } from '../../data-path';
import { API_URL } from '../../officer-url.mjs';

// The officer-caldav sidecar. Owns the whole CalDAV/CardDAV contract: it supervises Radicale, owns the
// collection storage under DATA_PATH/dav, and exposes two very different doors.
//
// ─────────────────────────────────────────────────────────────────────────────────────────────────
// HTTP CONTRACT — the platform strips its mount prefix before forwarding.
//
//   ANY  /dav/*          the DAV door. Forwarded verbatim to Radicale, including PROPFIND, REPORT,
//                        MKCOL, MKCALENDAR, COPY, MOVE, LOCK and UNLOCK, and every DAV header.
//                        Reached from the platform's TOP-LEVEL /dav mount, which authenticates a DAV
//                        app password over HTTP Basic. This is the door phones use.
//   GET  /_health        is Radicale up, and does it answer.
//   GET  /_officer/*     the JSON door for Officer's own web UI (see collections.ts).
//
// WHY TWO DOORS. A browser should not speak DAV — rendering a month view out of multistatus XML is a
// lot of machinery, and it would make the web UI as fragile as the protocol. The collections are on
// local disk here, so this sidecar can serve the UI plain JSON over the same data while DAV stays the
// machine-facing interface. Same split officer-email already uses.
// ─────────────────────────────────────────────────────────────────────────────────────────────────

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

// Where the platform exposes the DAV door publicly. It is a fixed contract between hono.ts's top-level
// mount and this sidecar, not configuration — Radicale has to be told it so the hrefs it generates are
// the ones the client can actually fetch.
const DAV_MOUNT = '/dav';

const paths = davPaths(DATA_PATH);
const radicale = startRadicale({ ...paths, port: getFreePort() });
const port = getFreePort();

// Headers a DAV exchange cannot survive without. `Depth` alone decides whether a PROPFIND returns the
// collection, its children, or the whole tree — drop it and every client sees an empty calendar. The
// rest carry conditional writes (If-Match is how a client avoids clobbering a concurrent edit),
// COPY/MOVE targets, and lock tokens.
const DAV_REQUEST_HEADERS = [
  'depth',
  'content-type',
  'content-length',
  'if',
  'if-match',
  'if-none-match',
  'destination',
  'overwrite',
  'lock-token',
  'timeout',
  'prefer',
  'user-agent',
] as const;

// Sent back untouched. `DAV` and `Allow` are how a client discovers what the server supports; getting
// them wrong makes iOS decide the account is not a calendar at all.
const DAV_RESPONSE_HEADERS = [
  'content-type',
  'dav',
  'allow',
  'etag',
  'last-modified',
  'location',
  'lock-token',
  'preference-applied',
  'vary',
] as const;

async function forwardToRadicale(req: Request, subpath: string, userId: string): Promise<Response> {
  const url = new URL(req.url);
  const target = `${radicale.url}${subpath}${url.search}`;

  const headers: Record<string, string> = {};
  for (const name of DAV_REQUEST_HEADERS) {
    const value = req.headers.get(name);
    if (value) headers[name] = value;
  }
  // Radicale is configured with `type = http_x_remote_user` and does no authentication of its own. It
  // binds loopback, so this header is only settable from inside this process.
  headers['X-Remote-User'] = userId;
  // Radicale generates ABSOLUTE hrefs in its multistatus bodies, and it builds them from where it
  // thinks it is mounted. Without this it answers a PROPFIND on /dav/ with
  // `<current-user-principal><href>/1/</href>`, the client dutifully requests /1/, and the platform
  // serves it the SPA — an HTML page where a calendar was expected. The client does not report a
  // useful error for that; the account just appears to have no calendars in it.
  headers['X-Script-Name'] = DAV_MOUNT;

  const hasBody = req.method !== 'GET' && req.method !== 'HEAD';

  let upstream: Response;
  try {
    upstream = await fetch(target, {
      method: req.method,
      headers,
      body: hasBody ? await req.arrayBuffer() : undefined,
    });
  } catch (err) {
    // Path only — a DAV body is the owner's calendar and address book content.
    console.error('[caldav] radicale unreachable', { path: subpath, error: String(err) });
    return new Response('caldav upstream unreachable', { status: 502 });
  }

  const out = new Headers();
  for (const name of DAV_RESPONSE_HEADERS) {
    const value = upstream.headers.get(name);
    if (value) out.set(name, value);
  }
  return new Response(upstream.body, { status: upstream.status, headers: out });
}

const server = Bun.serve({
  port,
  hostname: '127.0.0.1',
  // A contact photo or a calendar with years of history arrives as one PUT.
  maxRequestBodySize: 32 * 1024 * 1024,
  async fetch(req) {
    const url = new URL(req.url);

    const officerUser = req.headers.get('X-Officer-User');
    const userId = Number(officerUser);
    if (!officerUser || !Number.isInteger(userId) || userId <= 0) {
      return new Response('missing or invalid X-Officer-User', { status: 401 });
    }

    if (url.pathname === '/_health') {
      if (!radicale.isRunning()) {
        return Response.json({ ok: false, running: false, error: 'radicale not running' }, { status: 503 });
      }
      try {
        const probe = await fetch(`${radicale.url}/`, {
          method: 'PROPFIND',
          headers: { 'X-Remote-User': String(userId), Depth: '0' },
          signal: AbortSignal.timeout(5000),
        });
        // Anything that answers the DAV handshake is healthy; 207 Multi-Status is the expected reply.
        return Response.json({ ok: probe.status < 500, running: true, status: probe.status });
      } catch (err) {
        return Response.json({ ok: false, running: true, error: String(err) }, { status: 502 });
      }
    }

    if (url.pathname === '/dav' || url.pathname.startsWith('/dav/')) {
      const subpath = url.pathname.slice('/dav'.length) || '/';
      return forwardToRadicale(req, subpath, String(userId));
    }

    // The JSON door. Same data, no XML: a month view built out of multistatus responses would make the
    // web UI as fragile as the protocol.
    if (url.pathname.startsWith('/_officer/')) {
      // Radicale generates hrefs prefixed with /dav (X-Script-Name), and the UI hands those back
      // verbatim as collection ids — so strip the prefix again on the way in.
      const fetcher = (path: string, init: RequestInit) =>
        fetch(`${radicale.url}${path.replace(/^\/dav/, '')}`, {
          ...init,
          headers: {
            ...(init.headers as Record<string, string>),
            'X-Remote-User': String(userId),
            // Same prefix the phone-facing door sends, so the collection ids the UI receives here are
            // the same strings a DAV client would see. Without it the UI gets /1/work/ and the phone
            // gets /dav/1/work/ for the same collection, and nothing downstream can match them up.
            'X-Script-Name': DAV_MOUNT,
          },
        });

      if (url.pathname === '/_officer/collections') {
        return Response.json({ collections: await listCollections(fetcher, String(userId)) });
      }

      const path = url.searchParams.get('collection');
      // Confine reads to this user's own tree. The header already scopes Radicale, but a path from a
      // query string is attacker-shaped input and should not be handed on unchecked.
      if (!path || !path.startsWith(`/dav/${userId}/`)) {
        return Response.json({ error: 'collection is required' }, { status: 400 });
      }

      if (url.pathname === '/_officer/events') {
        return Response.json({ events: await listEvents(fetcher, path) });
      }
      if (url.pathname === '/_officer/contacts') {
        return Response.json({ contacts: await listContacts(fetcher, path) });
      }
    }

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

console.log(`[caldav] listening on 127.0.0.1:${port}; radicale on ${radicale.url}; storage ${paths.storagePath}`);

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}`,
      });
  }
}

const connection = createSidecarConnector({
  apiUrl: `${API_URL}/api/sidecar/register`,
  name: 'caldav',
  capabilities: ['caldav'],
  onCommand(cmd, reply) {
    handleCommand(cmd as SidecarCommand, reply as ReplyFn);
  },
  onConnected() {
    connection.send({ type: 'caldav:server', port });
    console.log(`[caldav] reported server port ${port} to API`);
  },
});

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

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