import type { SidecarCommand, SidecarEvent } from '../protocol';
import { createSidecarConnector } from '../connect';
import { handleConfigRoute, noteProbe, probe } from './config';
import { handleLockedRoute } from './locked';
import { handleOfficerRoute } from './routes';
import { getConfig } from './upstream';
import { API_URL } from '../../officer-url.mjs';

// The officer-photos sidecar. Owns the whole Immich contract for Officer: the instance URL and the API key.
// The platform API is a thin auth-gated forwarder (src/servers/api/photos/router.ts) holding no Immich
// credentials.
//
// The connection is the OWNER'S to set, from the UI — it is stored encrypted in `photos_config` and no
// longer read from the environment. See upstream.ts for why that move mattered.
//
// Named `photos`, not `immich`: the feature is the owner's photo library, and Immich is the implementation
// behind it. The route surface below is Officer's, so swapping the backend would not move the mount point.
//
// Built against the LIVE instance, which reports 3.0.3 (`GET /api/server/version`, unauthenticated).
//
// ─────────────────────────────────────────────────────────────────────────────────────────────────
// HTTP CONTRACT — the platform strips its /api/photos mount prefix before forwarding.
//
//   GET  /_health              ours. Confirms the key is live; reports the Immich version and who the key is.
//   GET  /_config              the account registry MINUS every key: { configured, activeId, accounts[] }
//   POST /_config              { label?, url, and EITHER apiKey OR email+password } — the password mints a
//                              key and is never stored. Validated against the instance, then stored encrypted
//   PATCH /_config/:id         same fields, all optional — no credential keeps the stored key
//   POST /_config/:id/activate switch to that account
//   DEL  /_config/:id          remove it; the newest survivor is promoted if it was the active one
//   *    /_officer/<path>      forwarded to <active url>/api/<path>, first-segment allow-list (routes.ts)
//
//   GET  /_locked/status       { hasPin, hasPassword, unlocked, expiresAt } — the private folder's state
//   POST /_locked/pin          { pinCode } set one up; PUT changes it, DELETE removes it (API key is enough)
//   POST /_locked/unlock       { password, pinCode } — signs in, elevates, holds the session IN MEMORY
//   POST /_locked/lock         drops elevation and closes the session
//   *    /_locked/api/<path>   forwarded on that session — assets, search and timeline only (locked.ts)
//
//   anything else              404
//
// 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.
//
// So `/api/photos/_officer/albums` on the platform is `/api/albums` on Immich, and
// `/api/photos/_officer/assets/<id>/thumbnail?size=preview` streams the thumbnail bytes back, Range and
// ETag included. The administrative half of Immich's API is unreachable — see routes.ts for the list.
// ─────────────────────────────────────────────────────────────────────────────────────────────────

/** 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',
  // Uploads go through this proxy as multipart bodies, and a phone's video is not small.
  maxRequestBodySize: 4 * 1024 * 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 });
    }

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

    const cfg = await getConfig(userId);

    // 503 with `configured: false` is the signal the UI turns into the setup form. Distinguishing it from a
    // configured-but-broken instance 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, account: cfg.label, version: result.version, error: result.error, ms },
          { status: 502 },
        );
      }
      await noteProbe(userId, cfg.id, result.version);
      return Response.json({
        ok: true,
        configured: true,
        account: cfg.label,
        version: result.version,
        user: result.user,
        ms,
      });
    }

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

    if (url.pathname === '/_locked' || url.pathname.startsWith('/_locked/')) {
      if (!cfg) return Response.json({ error: 'photos not connected', configured: false }, { status: 503 });
      try {
        return await handleLockedRoute(req, cfg, userId, url.pathname.slice('/_locked'.length));
      } catch (err) {
        // Never let the error text out: this surface handles a password and a PIN, and an upstream failure
        // carrying either into a response body or a log line is the one leak worth being paranoid about.
        console.error(`[photos] ${req.method} ${url.pathname} failed`);
        return Response.json({ error: 'internal error' }, { status: 500 });
      }
    }

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

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

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

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