import type { HeadscaleServerCredentials } from '../db/queries';
import { badRequest, methodNotAllowed, notFound, readJson, type OfficerContext } from './routes';
import { activeCreds, callCompanion, readBody, unavailable } from './companion';

// Enrolment invites — the admin half of COMMS/OFFSCALE_INVITE_ENROLLMENT.md. An admin mints a single-use
// invite, sends the link to whoever needs to join, and their phone exchanges the claim token for a pre-auth
// key it never had to be told.
//
// WHY THESE PROXY THE COMPANION RATHER THAN LIVING HERE. The invite store has to sit somewhere the joining
// phone can reach without an Officer account, and this sidecar is not that: it binds loopback on an
// ephemeral port behind Officer's auth. The spec's own argument settles it — an invite must still work when
// the platform is down, because the tailnet is often how you reach the platform. So the invite records, the
// token hashing and the claim endpoint belong next to Headscale, on its public origin, which is exactly what
// the Officer Companion already is. Officer is the admin surface and nothing more: create, list, revoke.
//
// Officer therefore stores no invite and no token. §5: "Never display, log or store the claim token beyond
// the moment it is handed to the admin." The create response passes through this process once, in memory,
// on its way to the browser — that is the whole of its life here.
//
// A server without the enrolment API answers `{available: false, reason}` at HTTP 200, like every other
// companion route: most registered servers have no companion at all, and that is a state to render rather
// than a request that failed.

/**
 * Where the invite API sits on the companion, under its own `/officer-api` mount — so the full URL is
 * `${server.url}/officer-api/api/v1/enroll/invites`. Versioned separately from the companion's container
 * routes (`/health`, `/logs`, `/restart`), which are unversioned; one constant so the two cannot drift.
 */
const INVITES_PATH = '/api/v1/enroll/invites';

/** Spec §4.1: default 900, max 86400. The floor is ours — a sub-minute invite cannot be sent to anyone. */
const DEFAULT_TTL_SECONDS = 900;
const MIN_TTL_SECONDS = 60;
const MAX_TTL_SECONDS = 86_400;

type CreateInput = {
  user: string;
  ttlSeconds: number;
  ephemeral: boolean;
  tags: string[];
  note?: string;
};

/** Validate the admin's form into the companion's request body, or a 400 saying which field was wrong. */
function parseCreate(body: Record<string, unknown> | null): CreateInput | Response {
  const user = typeof body?.user === 'string' ? body.user.trim() : '';
  if (!user) return badRequest('user is required — an invite files the joining device under one Headscale user');

  const raw = body?.ttlSeconds;
  const ttlSeconds = raw === undefined || raw === null ? DEFAULT_TTL_SECONDS : Number(raw);
  if (!Number.isInteger(ttlSeconds) || ttlSeconds < MIN_TTL_SECONDS || ttlSeconds > MAX_TTL_SECONDS) {
    return badRequest(`ttlSeconds must be an integer between ${MIN_TTL_SECONDS} and ${MAX_TTL_SECONDS}`);
  }

  // Tags are admin-set and passed through opaquely (spec §9.2). The `tag:` prefix is Headscale's, and
  // adding it here means the admin can type either form without minting a key that silently has no tag.
  const tags = Array.isArray(body?.tags)
    ? [
        ...new Set(
          body.tags
            .filter((t): t is string => typeof t === 'string')
            .map((t) => t.trim())
            .filter(Boolean)
            .map((t) => (t.startsWith('tag:') ? t : `tag:${t}`)),
        ),
      ]
    : [];

  const note = typeof body?.note === 'string' ? body.note.trim().slice(0, 200) : '';

  return { user, ttlSeconds, ephemeral: body?.ephemeral === true, tags, ...(note ? { note } : {}) };
}

/**
 * Turn a companion answer into ours.
 *
 * The three cases are distinct and the UI needs them to stay that way: unreachable is `available: false`
 * (render an explanation), a companion refusal keeps its own status and message (the admin typed something
 * the server rejected), and success is the body with `available: true` on it.
 */
async function relay(res: Response | string, wrap: (body: Record<string, unknown>) => unknown): Promise<Response> {
  if (typeof res === 'string') return Response.json(unavailable(res));

  const body = await readBody(res);
  if (typeof body === 'string') return Response.json(unavailable(body));

  if (!res.ok) {
    const error = typeof body.error === 'string' ? body.error : `the companion returned ${res.status}`;
    return Response.json(
      { error, code: typeof body.code === 'string' ? body.code : undefined },
      { status: res.status },
    );
  }

  return Response.json(wrap(body));
}

/**
 * Carry the admin's device name in the link's fragment, as `n=<percent-encoded>`.
 *
 * The companion already knows the name — it stores the note and hands it back as `suggestedHostname` on
 * claim — but a claim only happens when the person taps Join, which is one step AFTER the screen that asks
 * them to name the device. So the name has to arrive with the link if the field is to be prefilled, and the
 * link is the last thing that passes through here.
 *
 * Safe at every hop: the fragment is never sent to a server, the companion's /join page copies it verbatim
 * into the `officer-offscale://` deep link, and a build of the app that predates this ignores an unknown
 * parameter and still gets the name from `suggestedHostname` at claim time. Percent-encoded rather than
 * base64url (which `s` uses) because the app's fragment parser already decodeURIComponent()s every value,
 * and because base64url of a non-ASCII name would decode to mojibake on Hermes.
 */
function withNameHint(url: unknown, name: string | undefined): unknown {
  if (typeof url !== 'string' || !name || !url.includes('#')) return url;
  return `${url}&n=${encodeURIComponent(name)}`;
}

/**
 * `POST /_officer/enroll/invites` — mint an invite. The response carries the link, and only this once.
 *
 * `url` is an ordinary HTTPS link to a page on the server's own domain, which bounces into the app; the
 * companion also returns `deepLink`, the `officer-offscale://` scheme that page redirects to. That one is
 * dropped here rather than passed on: it carries the same claim token in its fragment, and a second copy of
 * a single-use credential in the browser is a second chance to leak it. Nothing on our side opens it.
 */
async function create(creds: HeadscaleServerCredentials, ctx: OfficerContext): Promise<Response> {
  const input = parseCreate(await readJson(ctx.req));
  if (input instanceof Response) return input;

  const res = await callCompanion(creds, { path: INVITES_PATH, method: 'POST', body: input });
  return relay(res, (body) => {
    const raw = body.invite ?? body;
    const invite = raw && typeof raw === 'object' ? (raw as Record<string, unknown>) : {};
    const { deepLink: _deepLink, ...rest } = invite;
    return { available: true, invite: { ...rest, url: withNameHint(rest.url, input.note) } };
  });
}

/**
 * Pull the invite array out of whatever envelope the companion used.
 *
 * §4.3 specifies the fields but not the wrapper, and the create response came back flat (no `invite` key),
 * so the list may equally be a bare array or sit under `invites`/`items`/`data`. Taking the first
 * array-valued property is shape-agnostic without being credulous: the body has exactly one array in it.
 */
function pickInvites(body: Record<string, unknown>): unknown[] {
  if (Array.isArray(body)) return body;
  for (const key of ['invites', 'items', 'data', 'results']) {
    const value = body[key];
    if (Array.isArray(value)) return value;
  }
  const found = Object.values(body).find(Array.isArray);
  return Array.isArray(found) ? found : [];
}

/** `GET /_officer/enroll/invites` — the admin's audit list. Never carries a token or a key. */
async function list(creds: HeadscaleServerCredentials): Promise<Response> {
  const res = await callCompanion(creds, { path: INVITES_PATH });
  return relay(res, (body) => ({ available: true, invites: pickInvites(body) }));
}

/** `DELETE /_officer/enroll/invites/:id` — revoke an unclaimed invite. A no-op on a claimed one. */
async function revoke(creds: HeadscaleServerCredentials, id: string): Promise<Response> {
  const res = await callCompanion(creds, { path: `${INVITES_PATH}/${encodeURIComponent(id)}`, method: 'DELETE' });
  return relay(res, (body) => ({ available: true, ...body }));
}

/** Dispatch `/_officer/enroll/invites...`. Acts on the ACTIVE server, like every other domain route. */
export async function handleInvitesRoute(ctx: OfficerContext, rest: string[]): Promise<Response> {
  const creds = await activeCreds(ctx.userId);
  if (creds instanceof Response) return creds;

  const [id, extra] = rest;
  if (extra) return notFound();

  if (!id) {
    if (ctx.req.method === 'POST') return create(creds, ctx);
    if (ctx.req.method === 'GET') return list(creds);
    return methodNotAllowed();
  }

  if (ctx.req.method !== 'DELETE') return methodNotAllowed();
  return revoke(creds, id);
}
