import type { SidecarCommand, SidecarEvent } from '../protocol';
import { createSidecarConnector } from '../connect';
import { resetStaleSoulseekBrowses } from 'officerdb';
import { getSlskdConfig, stripHopByHop } from './upstream';
import { handleConfigRoute, probe } from './config';
import { handleOfficerRoute } from './officer';
import { API_URL } from '../../officer-url.mjs';

// The officer-slskd sidecar. Same philosophy as officer-vault / officer-music: a singleton process that
// registers with the API server and OWNS a contract — here, a reverse-proxy to a self-hosted slskd
// (Soulseek daemon). ALL knowledge of slskd (its URL, its API key) lives here; the platform API is a
// thin, auth-gated forwarder that never sees the slskd credential. This lets the Soulseek client grow
// end-to-end without the main server accreting any slskd-specific logic.
//
// ─────────────────────────────────────────────────────────────────────────────────────────────────
// HTTP CONTRACT — the platform strips its /api/slskd mount prefix before forwarding, so requests arrive
// here as slskd-root paths (e.g. /api/v0/searches, /api/v0/transfers, /api/v0/session). We inject
// `X-API-Key` and pass method, path, query, headers, status and BOTH body streams through verbatim.
// The server listens on a random loopback port, reported to the API on connect so it can route here.
//
//   GET  /_health    ours (probes slskd's /health), not part of the slskd contract
//   GET  /_config    { configured, connection } — the URL and whether a key is stored, never the key
//   PUT  /_config    { url, apiKey? } — validated against the daemon, then stored encrypted. A blank
//                    apiKey on an existing connection keeps the stored one
//   DEL  /_config    forget the connection
//
// Paths under `/_officer/` are also ours and are NOT forwarded: they're the features slskd has no concept
// of (favourite peers, …), served straight from Postgres. See officer.ts for that contract. Keeping them
// here rather than in the platform API is what lets the main server stay a pure proxy forever.
//
// Every route needs `X-Officer-User`, which the platform proxy sets after authenticating the owner. We bind
// loopback only, so its presence is the trust signal — a request without it did not come through the
// platform, and the connection is per-owner data.
// ─────────────────────────────────────────────────────────────────────────────────────────────────
//
// TODO (next iteration): slskd streams live search results + transfer progress over SignalR hubs at
// /hub/*. Add a WebSocket upgrade path here mirroring the officer-vault notifications proxy
// (src/servers/sidecar/vault/index.ts) once the client needs real-time updates. SignalR carries its
// credential as an `?access_token=` query param on the socket, so the key injection differs from HTTP.

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

const port = getFreePort();

const server = Bun.serve({
  port,
  hostname: '127.0.0.1',
  idleTimeout: 255, // browse/search can idle; Bun caps this at 255s
  maxRequestBodySize: 1024 * 1024 * 1024, // room for uploads / large browse responses
  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 });
    }

    if (url.pathname === '/_config' || url.pathname.startsWith('/_config/')) {
      try {
        return await handleConfigRoute(req, userId, url.pathname.slice('/_config'.length));
      } catch (err) {
        console.error(`[slskd] ${req.method} ${url.pathname} failed`, err);
        return Response.json({ error: 'internal error' }, { status: 500 });
      }
    }

    const cfg = await getSlskdConfig(userId);

    // 503 with `configured: false` is the signal the UI turns into the setup form. Distinguishing it from a
    // configured-but-broken daemon is the whole reason the flag is on the response.
    if (url.pathname === '/_health') {
      if (!cfg) return Response.json({ ok: false, configured: false, error: 'not connected' }, { status: 503 });
      const started = Date.now();
      const result = await probe(cfg);
      const ms = Date.now() - started;
      if (!result.ok) return Response.json({ ok: false, configured: true, error: result.error, ms }, { status: 502 });
      return Response.json({ ok: true, configured: true, version: result.version, server: result.server, ms });
    }

    // Officer-owned routes — answered locally, never proxied (so they work even with slskd down).
    if (url.pathname.startsWith('/_officer/')) {
      try {
        const res = await handleOfficerRoute(req, url);
        if (res) return res;
        return new Response('not found', { status: 404 });
      } catch (err) {
        console.error(`[slskd] ${req.method} ${url.pathname} failed`, err);
        return Response.json({ error: 'internal error' }, { status: 500 });
      }
    }

    if (!cfg) return Response.json({ error: 'slskd not connected', configured: false }, { status: 503 });

    const target = `${cfg.base}${url.pathname}${url.search}`;
    const method = req.method;
    const hasBody = method !== 'GET' && method !== 'HEAD';
    const started = Date.now();

    const headers = stripHopByHop(req.headers);
    if (cfg.apiKey) headers.set('X-API-Key', cfg.apiKey);

    // Bun/undici require half-duplex to stream a request body straight through.
    const init: RequestInit & { duplex?: 'half' } = {
      method,
      headers,
      body: hasBody ? req.body : undefined,
      redirect: 'manual', // a transparent proxy passes 3xx through rather than following them
    };
    if (hasBody) init.duplex = 'half';

    let upstream: Response;
    try {
      upstream = await fetch(target, init);
    } catch {
      console.error(`[slskd] ${method} ${url.pathname} -> upstream unreachable`);
      return new Response('slskd upstream unreachable', { status: 502 });
    }

    console.log(`[slskd] ${method} ${url.pathname} -> ${upstream.status} ${Date.now() - started}ms`);
    return new Response(upstream.body, { status: upstream.status, headers: stripHopByHop(upstream.headers) });
  },
});

// No upstream in the banner: where slskd lives is now per-owner state read from the database per request,
// not a constant this process knows at boot.
console.log(`[slskd] reverse-proxy listening on 127.0.0.1:${port}`);

// A background share-tree fetch dies with this process, so anything left 'pending' from the previous life
// would spin in the UI forever. Clear it once, at boot, before serving.
resetStaleSoulseekBrowses()
  .then((n) => n && console.log(`[slskd] failed ${n} browse snapshot(s) interrupted by restart`))
  .catch((err) => console.error('[slskd] could not reset stale browse snapshots', err));

// ── 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: 'slskd',
  capabilities: ['slskd'],
  onCommand(cmd, reply) {
    handleCommand(cmd as SidecarCommand, reply as ReplyFn);
  },
  onConnected() {
    // Tell the API where our proxy is listening, so it can forward /api/slskd/* here.
    connection.send({ type: 'slskd:server', port });
    console.log(`[slskd] reported proxy port ${port} to API`);
  },
});

// ── Graceful shutdown ──

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