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

// The officer-invoiceshelf sidecar. Owns the whole InvoiceShelf contract for Officer: the instance URL, the
// Sanctum API token, and the `company` header that scopes every request. The platform API is a thin
// auth-gated forwarder (src/servers/api/invoiceshelf/router.ts) holding no InvoiceShelf credentials.
//
// The connection is the OWNER'S to set, from the UI — accounts are stored encrypted in
// `invoiceshelf_accounts` and no longer read from the environment. See upstream.ts for why that mattered.
//
// Built and verified against the LIVE instance, which runs 2.4.2 — NOT against the 3.0.0-alpha.1 checkout in
// _references/InvoiceShelf. The two differ in ways that matter (2.4.2 has no invoices/{id}/convert-to-estimate
// and no installation/is-installed). The route allow-list in routes.ts came from `artisan route:list` on the
// running container.
//
// ─────────────────────────────────────────────────────────────────────────────────────────────────
// HTTP CONTRACT — the platform strips its /api/invoiceshelf mount prefix before forwarding.
//
//   GET  /_health                          ours. Confirms the token is live and reports the pinned company.
//   GET  /_config                          the account registry MINUS every token
//   POST /_config                          { label?, url, companyId?, and EITHER token OR email+password }
//                                          — the password mints a token and is never stored.
//                                          409 { needsChoice, companies } when the company is ambiguous.
//   PATCH /_config/:id                     same fields, all optional — no credential keeps the stored token
//   GET  /_config/:id/companies            the companies that account's token can act for
//   POST /_config/:id/activate             switch to that account
//   DEL  /_config/:id                      remove it; the newest survivor is promoted if it was active
//   GET  /_officer/summary                 me + current company + dashboard totals, one call
//   GET  /_officer/lookups                 customers/items/units/tax-types/categories/payment-methods/currencies
//   GET  /_officer/bootstrap               the upstream's own bootstrap blob
//   GET  /_officer/next-number?key=invoice the number the next document would take
//   GET  /_officer/search?search=          cross-entity search
//   GET  /_officer/reports/:kind           sales-customers|sales-items|tax-summary|profit-loss|expenses → PDF
//
//   GET|POST        /_officer/:resource            list / create
//   GET|PUT|DELETE  /_officer/:resource/:id        read / update / delete
//   POST            /_officer/:resource/delete     bulk delete {ids:[…]}
//   GET             /_officer/:resource/templates  invoices|estimates only
//   GET             /_officer/:resource/:id/pdf    invoices|estimates|payments — magic-byte repaired
//   GET             /_officer/:resource/:id/preview   rendered email HTML, sends nothing.
//                                                     Requires ?subject=&body=&from=&to= — the controller
//                                                     validates them and 422s otherwise.
//   GET             /_officer/customers/:id/stats
//   POST            /_officer/:resource/:id/:action   status|clone|send|convert-to-invoice|duplicate
//   anything else                                     404
//
// `:resource` is an allow-list (routes.ts). The administrative half of the API — backups, disks, modules,
// update/*, installation/*, mail config, settings writes, ownership transfer — is deliberately unreachable.
// ─────────────────────────────────────────────────────────────────────────────────────────────────

/** 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',
  // Expense receipts and company logos are uploaded as multipart bodies through this proxy.
  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 });
    }

    if (url.pathname === '/_config' || url.pathname.startsWith('/_config/')) {
      try {
        return await handleConfigRoute(req, userId, url.pathname.slice('/_config'.length));
      } catch (err) {
        console.error(`[invoiceshelf] ${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,
        company: cfg.companyId,
        ms,
      });
    }

    if (url.pathname.startsWith('/_officer/')) {
      if (!cfg) return Response.json({ error: 'invoiceshelf 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(`[invoiceshelf] ${req.method} ${url.pathname} failed`, err);
        return Response.json({ error: 'internal error' }, { status: 500 });
      }
    }

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

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

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

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