import type { SidecarCommand, SidecarEvent } from '../protocol';
import { createSidecarConnector } from '../connect';
import { getServiceConnection, saveServiceConnection, deleteServiceConnection, recordServiceProbe } from 'officerdb';
import { callMemos, getMemosConfig, invalidateMemosConfig, normalizeBase, probe } from './upstream';
import { API_URL } from '../../officer-url.mjs';

// The officer-memos sidecar. Owns the whole Memos contract: the instance URL and the personal access
// token. The platform side is a thin auth-gated forwarder holding no Memos credentials.
//
// ─────────────────────────────────────────────────────────────────────────────────────────────────
// HTTP CONTRACT — the platform strips its /api/memos mount prefix before forwarding.
//
//   GET    /_health          is the instance up, and does the stored token work
//   GET    /_config          the connection MINUS the token
//   POST   /_config          { url, token } — validated live before it is stored
//   DELETE /_config          forget the connection
//   ANY    /_api/*           forwarded to the instance's own /api/*, token attached
//
// `/_api/*` is a deliberate pass-through rather than a hand-written wrapper per endpoint. Memos' REST
// API is generated from its protobufs and moves between minor versions; re-describing it here would
// mean a second thing to keep in sync, and the UI is going to speak the upstream's shapes anyway.
// The allow-list below is the one piece of policy: it keeps the sidecar from being a general-purpose
// SSRF hop into whatever else is on that host.
// ─────────────────────────────────────────────────────────────────────────────────────────────────

// Everything under /api/v1 the UI legitimately needs. Auth routes are excluded on purpose: signin and
// signout would mint or destroy sessions on the instance, and this sidecar authenticates with a stored
// token rather than borrowing the owner's Memos session.
const ALLOWED = [/^\/api\/v1\/memos(\/|$|\?)/, /^\/api\/v1\/users(\/|$|\?)/, /^\/api\/v1\/resources(\/|$|\?)/];

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;
}

const port = getFreePort();
const bad = (error: string, status = 400) => Response.json({ error }, { status });

async function handleConfig(req: Request, userId: number): Promise<Response> {
  if (req.method === 'GET') {
    const connection = await getServiceConnection(userId, 'memos');
    return Response.json({ configured: !!connection, connection });
  }

  if (req.method === 'DELETE') {
    const removed = await deleteServiceConnection(userId, 'memos');
    invalidateMemosConfig(userId);
    return Response.json({ ok: removed });
  }

  if (req.method === 'POST') {
    const body = ((await req.json().catch(() => null)) as { url?: unknown; token?: unknown } | null) ?? {};
    const url = typeof body.url === 'string' ? normalizeBase(body.url) : '';
    // A blank token on a save means "keep the stored one", so the URL can be corrected without the
    // token being re-pasted. There is no way to read it back out to re-send it.
    const token = typeof body.token === 'string' && body.token.trim() ? body.token.trim() : null;

    if (!url) return bad('url is required');
    if (!/^https?:\/\//i.test(url)) return bad('url must start with http:// or https://');

    const existing = await getServiceConnection(userId, 'memos');
    if (!token && !existing?.hasSecret) return bad('an access token is required the first time');

    // Probe with the credentials as they WILL be, so a save either stores something that works or
    // fails with the reason. Storing first and discovering later makes every downstream screen fail
    // mysteriously instead.
    const current = token ? null : await getMemosConfig(userId);
    const result = await probe({ id: existing?.id ?? 0, base: url, token: token ?? current?.token ?? null });
    if (!result.ok) return bad(result.error ?? 'could not reach the instance');

    await saveServiceConnection({
      userId,
      service: 'memos',
      url,
      secret: token ?? undefined,
      version: result.version,
    });
    invalidateMemosConfig(userId);
    return Response.json({ ok: true, user: result.user });
  }

  return bad('method not allowed', 405);
}

const server = Bun.serve({
  port,
  hostname: '127.0.0.1',
  // Memos resources are image/file attachments on a note.
  maxRequestBodySize: 64 * 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 Response.json({ error: 'missing or invalid X-Officer-User' }, { status: 401 });
    }

    try {
      if (url.pathname === '/_config') return await handleConfig(req, userId);

      const config = await getMemosConfig(userId);

      if (url.pathname === '/_health') {
        if (!config) return Response.json({ ok: false, configured: false, error: 'not connected' }, { status: 503 });
        const started = Date.now();
        const result = await probe(config);
        const ms = Date.now() - started;
        if (!result.ok) {
          return Response.json({ ok: false, configured: true, error: result.error, ms }, { status: 502 });
        }
        await recordServiceProbe(userId, 'memos', result.version);
        return Response.json({ ok: true, configured: true, user: result.user, ms });
      }

      if (url.pathname.startsWith('/_api/')) {
        if (!config) return Response.json({ error: 'memos not connected', configured: false }, { status: 503 });
        const subpath = url.pathname.slice('/_api'.length) + url.search;
        if (!ALLOWED.some((re) => re.test(subpath))) return bad('path not allowed', 403);

        const hasBody = req.method !== 'GET' && req.method !== 'HEAD';
        const upstream = await callMemos(config, subpath, {
          method: req.method,
          headers: req.headers.get('content-type') ? { 'content-type': req.headers.get('content-type')! } : {},
          body: hasBody ? await req.arrayBuffer() : undefined,
        });
        return new Response(upstream.body, {
          status: upstream.status,
          headers: { 'content-type': upstream.headers.get('content-type') ?? 'application/json' },
        });
      }
    } catch (err) {
      // Path only. A memo body is the owner's private note.
      console.error(`[memos] ${req.method} ${url.pathname} failed`, String(err));
      return Response.json({ error: 'internal error' }, { status: 500 });
    }

    return Response.json({ error: 'not found' }, { status: 404 });
  },
});

console.log(`[memos] listening on 127.0.0.1:${port} (instance configured from the UI, stored in service_connections)`);

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: 'memos',
  capabilities: ['memos'],
  onCommand(cmd, reply) {
    handleCommand(cmd as SidecarCommand, reply as ReplyFn);
  },
  onConnected() {
    connection.send({ type: 'memos:server', port });
    console.log(`[memos] reported server port ${port} to API`);
  },
});

function shutdown(signal: string) {
  console.log(`[memos] ${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'));
