import type { OfficerContext } from './routes';
import { badRequest, notFound, methodNotAllowed, readJson } from './routes';
import type { HeadscaleClient } from './client';
import { activeClient } from './active';
import { toNode, arrayField, type OfficerNode } from './normalize';

// Node routes — /_officer/nodes/*. A "node" is a machine in the tailnet.
//
// Two upstream shapes are worth knowing before reading this:
//
//  • Renaming takes the new name in the PATH (`/node/{id}/rename/{newName}`), not a body. It must be
//    encodeURIComponent'd or a name with a slash silently becomes a 404 on a different route.
//  • Route approval is a whole-SET write (`approve_routes` replaces the approved list), not an
//    add/remove. Approving one route means sending every route that should remain approved, so those
//    operations are read-modify-write here rather than in the browser — see rule 5 in
//    SIDECAR_ARCHITECTURE.md. Doing it client-side would make two admins racing lose each other's edits;
//    doing it here still races, but over milliseconds instead of however long a form sits open.

/** Nodes on the active server, newest-registered first within each user. */
async function listNodes(ctx: OfficerContext): Promise<Response> {
  const client = await activeClient(ctx.userId);
  if (client instanceof Response) return client;

  // The upstream `user` filter takes a USERNAME, not an id — a trap worth keeping out of the browser.
  const user = ctx.url.searchParams.get('user');
  const path = user ? `/api/v1/node?user=${encodeURIComponent(user)}` : '/api/v1/node';

  const body = await client.call(path);
  const nodes = arrayField(body, 'nodes').map(toNode);
  nodes.sort((a, b) => Number(b.online) - Number(a.online) || a.name.localeCompare(b.name));
  return Response.json({ nodes });
}

/** Re-read one node after a mutation. Headscale's mutation responses are inconsistent; a GET never is. */
async function getNode(client: HeadscaleClient, id: string): Promise<OfficerNode | null> {
  const body = await client.call<{ node?: Record<string, unknown> }>(`/api/v1/node/${encodeURIComponent(id)}`);
  return body.node ? toNode(body.node) : null;
}

type NodeActionParams = { ctx: OfficerContext; id: string; action: string | undefined };

async function handleNodeAction({ ctx, id, action }: NodeActionParams): Promise<Response> {
  const { req } = ctx;
  const client = await activeClient(ctx.userId);
  if (client instanceof Response) return client;

  if (action === undefined) {
    if (req.method === 'GET') {
      const node = await getNode(client, id);
      return node ? Response.json({ node }) : notFound('no such node');
    }
    if (req.method === 'DELETE') {
      await client.call(`/api/v1/node/${encodeURIComponent(id)}`, { method: 'DELETE' });
      return new Response(null, { status: 204 });
    }
    return methodNotAllowed();
  }

  if (req.method !== 'POST') return methodNotAllowed();

  if (action === 'rename') {
    const body = await readJson(req);
    if (!body) return badRequest('expected a JSON body');
    const name = typeof body.name === 'string' ? body.name.trim() : '';
    if (!name) return badRequest('name is required');
    await client.call(`/api/v1/node/${encodeURIComponent(id)}/rename/${encodeURIComponent(name)}`, { method: 'POST' });
    return Response.json({ node: await getNode(client, id) });
  }

  if (action === 'tags') {
    const body = await readJson(req);
    if (!body) return badRequest('expected a JSON body');
    if (!Array.isArray(body.tags)) return badRequest('tags must be an array of strings');
    const tags = body.tags.filter((t): t is string => typeof t === 'string').map((t) => t.trim());
    if (tags.some((t) => !t)) return badRequest('tags cannot be empty strings');
    // Headscale requires the `tag:` prefix and rejects anything else with a 500, which we'd surface as a
    // useless "headscale error". Normalizing here means the UI can accept either form.
    const prefixed = tags.map((t) => (t.startsWith('tag:') ? t : `tag:${t}`));
    await client.call(`/api/v1/node/${encodeURIComponent(id)}/tags`, { method: 'POST', body: { tags: prefixed } });
    return Response.json({ node: await getNode(client, id) });
  }

  if (action === 'routes') {
    const body = await readJson(req);
    if (!body) return badRequest('expected a JSON body');

    let routes: string[];
    if (Array.isArray(body.routes)) {
      // Whole-set write: the caller states the complete approved list.
      routes = body.routes.filter((r): r is string => typeof r === 'string');
    } else if (typeof body.route === 'string' && typeof body.approved === 'boolean') {
      // Single-toggle: read the current set, apply one change, write it back.
      const current = await getNode(client, id);
      if (!current) return notFound('no such node');
      const set = new Set(current.approvedRoutes);
      if (body.approved) set.add(body.route);
      else set.delete(body.route);
      routes = [...set];
    } else {
      return badRequest('expected {routes: string[]} or {route: string, approved: boolean}');
    }

    await client.call(`/api/v1/node/${encodeURIComponent(id)}/approve_routes`, { method: 'POST', body: { routes } });
    return Response.json({ node: await getNode(client, id) });
  }

  if (action === 'user') {
    const body = await readJson(req);
    if (!body) return badRequest('expected a JSON body');
    // Upstream takes the target user's numeric id, not its name — and uint64-as-string, so it is validated
    // by shape and passed through as a string rather than parsed.
    const userId = typeof body.userId === 'string' ? body.userId.trim() : '';
    if (!/^\d+$/.test(userId)) return badRequest('userId must be numeric');
    // Moving a node changes which ACL rules and tag ownership apply to it — the routes it advertises and
    // the tags it carries stay put, but what they now MEAN can differ. The UI says so before asking.
    await client.call(`/api/v1/node/${encodeURIComponent(id)}/user`, { method: 'POST', body: { user: userId } });
    return Response.json({ node: await getNode(client, id) });
  }

  if (action === 'expire') {
    // Expires the node's key, forcing it to re-authenticate. Not a delete: the node stays registered.
    await client.call(`/api/v1/node/${encodeURIComponent(id)}/expire`, { method: 'POST' });
    return Response.json({ node: await getNode(client, id) });
  }

  return notFound();
}

/** Dispatch `/_officer/nodes/...`. `rest` is the path after `nodes`. */
export async function handleNodesRoute(ctx: OfficerContext, rest: string[]): Promise<Response> {
  if (rest.length === 0) {
    if (ctx.req.method !== 'GET') return methodNotAllowed();
    return listNodes(ctx);
  }
  if (rest.length > 2) return notFound();

  const id = rest[0];
  // Upstream ids are uint64-as-string. Validate the shape without parsing — Number() would lose precision.
  if (!id || !/^\d+$/.test(id)) return badRequest('node id must be numeric');

  return handleNodeAction({ ctx, id, action: rest[1] });
}
