import type { OfficerContext } from './routes';
import { badRequest, methodNotAllowed, readJson } from './routes';
import { HeadscaleError } from './client';
import { activeClient } from './active';
import { handlePolicyAssistRoute } from './assist';

// The ACL policy — /_officer/policy. One HuJSON document that decides which node may reach which, so it is
// the highest-consequence thing this app can write and the only place a typo silently partitions a network.
//
// Three upstream behaviours drive the shape of this file.
//
//  1. **Readable always, writable sometimes.** Headscale can keep its policy in a file (`policy.mode: file`)
//     instead of the database, and then the API still SERVES it — a GET returns the file's contents quite
//     happily — but a PUT is refused with "update is disabled for modes other than 'database'". Verified
//     against a live server, and it means the mode CANNOT be inferred from a read. There is no endpoint
//     that reports it either. So this route makes no claim about writability up front; the first save is
//     what finds out, and a refusal is a 409 the UI turns into a persistent read-only banner.
//
//  2. **Validation happens on PUT, in Headscale, and its message is the whole value.** It parses the
//     HuJSON, resolves every group and tag reference, and rejects the write with a line and column or a
//     "group not defined" naming the offender. Officer must not pre-validate: a second, weaker parser here
//     would reject documents Headscale accepts and — worse — accept ones it rejects, and its opinion would
//     be the one shown. So the text goes up untouched and Headscale's verdict comes back verbatim.
//
//  3. **Both of those arrive as HTTP 500** from grpc-gateway, which the client layer normally generalizes
//     to "headscale error". `HeadscaleError.detail` is how the real message survives that; see client.ts.

/**
 * Does this failure mean "writing is turned off here", as opposed to "your document is wrong"?
 *
 * Matched on the message because Headscale gives no code to match on. Deliberately broad: a false positive
 * costs a slightly-wrong banner over a message the owner can still read, while a false negative would tell
 * someone their perfectly good ACL was rejected and send them hunting for a syntax error that isn't there.
 */
function isWriteDisabled(detail: string): boolean {
  const text = detail.toLowerCase();
  if (text.includes('disabled')) return true;
  return text.includes('file') && (text.includes('policy') || text.includes('mode'));
}

type PolicyBody = { policy?: unknown; updatedAt?: unknown };

const asText = (value: unknown) => (typeof value === 'string' ? value : '');
const asDate = (value: unknown) => (typeof value === 'string' && value && !value.startsWith('0001-') ? value : null);

/**
 * `GET /_officer/policy`.
 *
 * Answers 200 for every state a running server can be in, including "there is no policy yet" — a fresh
 * Headscale has none, and an empty editor is both the honest rendering of that and the thing the owner
 * needs to start typing into. Only an unreachable server is an error, because only that leaves nothing
 * to say. Note there is no `mode` here on purpose: see the header.
 */
async function getPolicy(ctx: OfficerContext): Promise<Response> {
  const client = await activeClient(ctx.userId);
  if (client instanceof Response) return client;

  try {
    const body = await client.call<PolicyBody>('/api/v1/policy');
    return Response.json({ policy: asText(body.policy), updatedAt: asDate(body.updatedAt) });
  } catch (err) {
    if (!(err instanceof HeadscaleError)) throw err;
    const detail = err.detail ?? err.message;
    if (err.status === 404 || detail.toLowerCase().includes('not found')) {
      return Response.json({ policy: '', updatedAt: null });
    }
    throw err;
  }
}

/**
 * `PUT /_officer/policy {policy}`.
 *
 * The body is sent up byte for byte — no trimming, no reformatting, no parse. Comments and layout are load
 * bearing in a hand-maintained ACL, and re-serializing would destroy both.
 */
async function putPolicy(ctx: OfficerContext): Promise<Response> {
  const client = await activeClient(ctx.userId);
  if (client instanceof Response) return client;

  const body = await readJson(ctx.req);
  if (!body) return badRequest('expected a JSON body');
  if (typeof body.policy !== 'string') return badRequest('policy must be a string');
  // An empty document would be accepted by some Headscale versions and lock every node out of every other
  // one. Deleting a policy is not something to do by leaving a textarea blank and pressing save.
  if (!body.policy.trim()) return badRequest('the policy is empty — that would deny every connection');

  try {
    const saved = await client.call<PolicyBody>('/api/v1/policy', { method: 'PUT', body: { policy: body.policy } });
    // Headscale echoes what it stored; fall back to what we sent if it echoes nothing, so a successful save
    // never blanks the editor.
    return Response.json({ policy: asText(saved.policy) || body.policy, updatedAt: asDate(saved.updatedAt) });
  } catch (err) {
    if (!(err instanceof HeadscaleError)) throw err;
    const detail = err.detail ?? err.message;

    if (isWriteDisabled(detail)) {
      return Response.json({ error: detail, code: 'policy_read_only' }, { status: 409 });
    }
    // Everything else on a PUT is Headscale rejecting this document: a syntax error with a position, an
    // unresolvable group, an unknown tag owner. 422 rather than 502 — the request is the problem, and the
    // message is the one thing that will fix it.
    return Response.json({ error: detail, code: 'policy_rejected' }, { status: 422 });
  }
}

/** Dispatch `/_officer/policy`. One policy per server, plus the drafting assistant beside it. */
export async function handlePolicyRoute(ctx: OfficerContext, rest: string[]): Promise<Response> {
  // `/policy/assist` proposes a document; it never writes one. See assist.ts.
  if (rest[0] === 'assist') return handlePolicyAssistRoute(ctx, rest.slice(1));
  if (rest.length > 0) return badRequest('unexpected path');
  if (ctx.req.method === 'GET') return getPolicy(ctx);
  if (ctx.req.method === 'PUT') return putPolicy(ctx);
  return methodNotAllowed();
}
