/**
 * Integration tests for routes/tailscale-admin.js — the Tailscale settings +
 * admin API surface (PUT/GET/DELETE settings, /admin/devices, /admin/keys).
 *
 * Strategy:
 *   - Use supertest against a real Express app mounting the router
 *   - Mock `tailscaleCoord` (the ctx namespace) so we don't hit real Tailscale
 *   - Mock `credentialManager` indirectly via the mocked `tailscaleCoord.setApiToken`
 *   - The route does `new TailscaleCoordClient(...)` inline for the validation
 *     path; we mock that whole module to inject a fake client
 */

/* eslint-disable require-await, no-unused-vars */
// require-await: many test helper stubs are `async () => value` to match the
// shape of the real function signatures — they don't need to await.
// no-unused-vars: `fakeClient = makeFakeClient()` in some tests exists only to
// satisfy the linter that the helper is reachable; tests that don't exercise a
// particular method intentionally leave it unused.

'use strict';

const express = require('express');
const request = require('supertest');

// --- Mock the coord client module so the PUT/POST routes can instantiate it
//     without making real HTTP calls.
jest.mock('../../src/managers/tailscale-coord', () => {
  const real = jest.requireActual('../../src/managers/tailscale-coord');
  return {
    ...real,
    TailscaleCoordClient: jest.fn(),
    TailscaleCoordError: real.TailscaleCoordError,
  };
});

const { TailscaleCoordClient, TailscaleCoordError } = require('../../src/managers/tailscale-coord');

function asyncHandler(fn) {
  return (req, res, _next) => Promise.resolve(fn(req, res, _next)).catch(_next);
}

function createApp({ initialMetadata = { configured: false }, initialToken = null, mockClient } = {}) {
  const stored = { token: initialToken };
  let metadata = initialMetadata;

  const tailscaleCoord = {
    loadMetadata: () => metadata,
    saveMetadata: (m) => { metadata = m; },
    setApiToken: jest.fn(async (token) => { stored.token = token; }),
    getClient: jest.fn(async () => {
      // If a token is stored, hand back the mockClient; otherwise a fresh
      // unconfigured mock
      const FakeClient = jest.requireActual('../../src/managers/tailscale-coord').TailscaleCoordClient;
      return new FakeClient({ apiToken: stored.token });
    }),
    hasApiToken: jest.fn(async () => !!stored.token),
  };

  const app = express();
  app.use(express.json());
  const routes = require('../../routes/tailscale-admin');
  app.use('/api/v1/tailscale', routes({
    tailscaleCoord,
    asyncHandler,
    log: { info: jest.fn(), error: jest.fn(), warn: jest.fn() },
    logError: jest.fn(),
  }));

  return { app, tailscaleCoord, stored, getMetadata: () => metadata };
}

// Helper: builds a fake coord client instance the way the route uses it
function makeFakeClient({ apiToken = 'tskey-api-fake', ping, listDevices, listAuthKeys, listUsers, createAuthKey, deleteAuthKey, deleteDevice, getAcl, updateAcl } = {}) {
  return {
    apiToken,
    isConfigured: () => !!apiToken,
    setApiToken: jest.fn(),
    ping: ping || jest.fn(async () => ({ domain: 'fake.ts.net' })),
    listDevices: listDevices || jest.fn(async () => []),
    listAuthKeys: listAuthKeys || jest.fn(async () => []),
    listUsers: listUsers || jest.fn(async () => []),
    createAuthKey: createAuthKey || jest.fn(async () => ({ id: 'k1', key: 'tskey-auth-fake' })),
    deleteAuthKey: deleteAuthKey || jest.fn(async () => ({ success: true })),
    deleteDevice: deleteDevice || jest.fn(async () => ({ success: true })),
    getAcl: getAcl || jest.fn(async () => ({ acls: [] })),
    updateAcl: updateAcl || jest.fn(async () => ({})),
  };
}

describe('routes/tailscale-admin: GET /settings', () => {
  test('returns configured:false when metadata is empty', async () => {
    const { app } = createApp();
    const res = await request(app).get('/api/v1/tailscale/settings');
    expect(res.status).toBe(200);
    expect(res.body.success).toBe(true);
    expect(res.body.configured).toBe(false);
  });

  test('returns tailnetName + deviceCount when configured', async () => {
    const { app } = createApp({
      initialMetadata: { configured: true, tailnetName: 'foo.ts.net', deviceCount: 9, keyValidatedAt: '2026-07-07T00:00:00Z' },
    });
    const res = await request(app).get('/api/v1/tailscale/settings');
    expect(res.status).toBe(200);
    expect(res.body.configured).toBe(true);
    expect(res.body.tailnetName).toBe('foo.ts.net');
    expect(res.body.deviceCount).toBe(9);
    expect(res.body.keyValidatedAt).toBe('2026-07-07T00:00:00Z');
  });

  test('never returns the raw token (even if it would be in metadata)', async () => {
    const { app } = createApp({
      initialMetadata: { configured: true, tailnetName: 'foo.ts.net', apiToken: 'SECRET-SHOULD-NOT-LEAK' },
    });
    const res = await request(app).get('/api/v1/tailscale/settings');
    expect(res.body.apiToken).toBeUndefined();
    expect(JSON.stringify(res.body)).not.toContain('SECRET-SHOULD-NOT-LEAK');
  });
});

describe('routes/tailscale-admin: PUT /settings', () => {
  test('400 on missing apiToken', async () => {
    const { app } = createApp();
    const res = await request(app).put('/api/v1/tailscale/settings').send({});
    expect(res.status).toBe(400);
  });

  test('400 on apiToken not starting with tskey-api-', async () => {
    const { app } = createApp();
    const res = await request(app).put('/api/v1/tailscale/settings').send({ apiToken: 'not-a-token' });
    expect(res.status).toBe(400);
  });

  test('400 on apiToken exceeding 256-char length cap (DC-080)', async () => {
    const { app } = createApp();
    const oversized = 'tskey-api-' + 'x'.repeat(300); // > 256 chars
    const res = await request(app).put('/api/v1/tailscale/settings').send({ apiToken: oversized });
    expect(res.status).toBe(400);
    expect(res.body.error || res.body.message).toMatch(/exceeds maximum length/i);
  });

  test('400 on non-string apiToken (DC-080)', async () => {
    const { app } = createApp();
    const res = await request(app).put('/api/v1/tailscale/settings').send({ apiToken: 12345 });
    expect(res.status).toBe(400);
  });

  test('200 + saves token + writes metadata on valid token', async () => {
    const fakeClient = makeFakeClient({
      ping: jest.fn(async () => ({ domain: 'real.ts.net' })),
      listDevices: jest.fn(async () => [{ id: 'd1' }, { id: 'd2' }, { id: 'd3' }]),
    });
    TailscaleCoordClient.mockImplementation(() => fakeClient);

    const { app, tailscaleCoord, stored } = createApp();
    const res = await request(app)
      .put('/api/v1/tailscale/settings')
      .send({ apiToken: 'tskey-api-valid-token' });

    expect(res.status).toBe(200);
    expect(res.body.configured).toBe(true);
    expect(res.body.tailnetName).toBe('real.ts.net');
    expect(res.body.deviceCount).toBe(3);
    expect(tailscaleCoord.setApiToken).toHaveBeenCalledWith('tskey-api-valid-token');
    expect(stored.token).toBe('tskey-api-valid-token');
  });

  test('401 on Tailscale rejection', async () => {
    const fakeClient = makeFakeClient({
      ping: jest.fn(async () => { throw new TailscaleCoordError('unauthorized', { status: 401, code: 'unauthorized' }); }),
    });
    TailscaleCoordClient.mockImplementation(() => fakeClient);

    const { app, tailscaleCoord } = createApp();
    const res = await request(app)
      .put('/api/v1/tailscale/settings')
      .send({ apiToken: 'tskey-api-bad-token' });

    expect(res.status).toBe(401);
    expect(tailscaleCoord.setApiToken).not.toHaveBeenCalled();
  });

  test('502 on other Tailscale errors', async () => {
    const fakeClient = makeFakeClient({
      ping: jest.fn(async () => { throw new TailscaleCoordError('server error', { status: 500, code: 'server_error' }); }),
    });
    TailscaleCoordClient.mockImplementation(() => fakeClient);

    const { app } = createApp();
    const res = await request(app)
      .put('/api/v1/tailscale/settings')
      .send({ apiToken: 'tskey-api-fails' });

    expect(res.status).toBe(502);
  });

  test('proceeds even if device count fetch fails', async () => {
    const fakeClient = makeFakeClient({
      ping: jest.fn(async () => ({ domain: 'real.ts.net' })),
      listDevices: jest.fn(async () => { throw new Error('boom'); }),
    });
    TailscaleCoordClient.mockImplementation(() => fakeClient);

    const { app } = createApp();
    const res = await request(app)
      .put('/api/v1/tailscale/settings')
      .send({ apiToken: 'tskey-api-valid-token' });
    expect(res.status).toBe(200);
    expect(res.body.deviceCount).toBeNull();
    expect(res.body.tailnetName).toBe('real.ts.net');
  });
});

describe('routes/tailscale-admin: DELETE /settings', () => {
  test('clears token + metadata, returns configured:false', async () => {
    const { app, tailscaleCoord, stored, getMetadata } = createApp({
      initialMetadata: { configured: true, tailnetName: 'foo.ts.net' },
      initialToken: 'tskey-api-something',
    });
    const res = await request(app).delete('/api/v1/tailscale/settings');
    expect(res.status).toBe(200);
    expect(res.body.configured).toBe(false);
    expect(tailscaleCoord.setApiToken).toHaveBeenCalledWith(null);
    expect(stored.token).toBeNull();
    expect(getMetadata()).toEqual({ configured: false });
  });
});

describe('routes/tailscale-admin: POST /settings/test', () => {
  test('returns valid:false when no token configured', async () => {
    const { app } = createApp({ initialToken: null });
    const res = await request(app).post('/api/v1/tailscale/settings/test').send({});
    expect(res.status).toBe(200);
    expect(res.body.valid).toBe(false);
    expect(res.body.error).toMatch(/no Tailscale API token/i);
  });

  test('returns valid:true + tailnetName on successful ping (stored token)', async () => {
    // Build an app where getClient returns a fake with our desired ping
    const fakeClient = makeFakeClient({ ping: jest.fn(async () => ({ domain: 'stored.ts.net' })) });
    const app = express();
    app.use(express.json());
    const routes = require('../../routes/tailscale-admin');
    const stored = { token: 'tskey-api-stored' };
    const tailscaleCoord = {
      loadMetadata: () => ({ configured: true }),
      saveMetadata: jest.fn(),
      setApiToken: jest.fn(),
      getClient: jest.fn(async () => fakeClient),
      hasApiToken: jest.fn(),
    };
    app.use('/api/v1/tailscale', routes({
      tailscaleCoord, asyncHandler,
      log: { info: jest.fn(), error: jest.fn(), warn: jest.fn() },
    }));
    const res = await request(app).post('/api/v1/tailscale/settings/test').send({});
    expect(res.status).toBe(200);
    expect(res.body.valid).toBe(true);
    expect(res.body.tailnetName).toBe('stored.ts.net');
    expect(fakeClient.ping).toHaveBeenCalledWith({ skipCache: true });
  });

  test('returns valid:false on Tailscale unauthorized', async () => {
    const fakeClient = makeFakeClient({
      ping: jest.fn(async () => { throw new TailscaleCoordError('unauthorized', { status: 401, code: 'unauthorized' }); }),
    });
    const app = express();
    app.use(express.json());
    const routes = require('../../routes/tailscale-admin');
    const tailscaleCoord = {
      loadMetadata: () => ({ configured: true }),
      saveMetadata: jest.fn(),
      setApiToken: jest.fn(),
      getClient: jest.fn(async () => fakeClient),
      hasApiToken: jest.fn(),
    };
    app.use('/api/v1/tailscale', routes({
      tailscaleCoord, asyncHandler,
      log: { info: jest.fn(), error: jest.fn(), warn: jest.fn() },
    }));
    const res = await request(app).post('/api/v1/tailscale/settings/test').send({});
    expect(res.status).toBe(200);
    expect(res.body.valid).toBe(false);
    expect(res.body.error).toMatch(/unauthorized/);
  });

  test('uses body.apiToken override when provided', async () => {
    const fakeClient = makeFakeClient({ ping: jest.fn(async () => ({ domain: 'override.ts.net' })) });
    const app = express();
    app.use(express.json());
    const routes = require('../../routes/tailscale-admin');
    const tailscaleCoord = {
      loadMetadata: () => ({ configured: false }),
      saveMetadata: jest.fn(),
      setApiToken: jest.fn(),
      getClient: jest.fn(async () => fakeClient), // pre-loaded fake
      hasApiToken: jest.fn(),
    };
    app.use('/api/v1/tailscale', routes({
      tailscaleCoord, asyncHandler,
      log: { info: jest.fn(), error: jest.fn(), warn: jest.fn() },
    }));
    const res = await request(app)
      .post('/api/v1/tailscale/settings/test')
      .send({ apiToken: 'tskey-api-test-only' });
    expect(res.status).toBe(200);
    expect(res.body.valid).toBe(true);
    expect(fakeClient.setApiToken).toHaveBeenCalledWith('tskey-api-test-only');
  });

  test('400 on body.apiToken not starting with tskey-api- (DC-080)', async () => {
    const fakeClient = makeFakeClient();
    const app = express();
    app.use(express.json());
    const routes = require('../../routes/tailscale-admin');
    const tailscaleCoord = {
      loadMetadata: () => ({ configured: false }),
      saveMetadata: jest.fn(),
      setApiToken: jest.fn(),
      getClient: jest.fn(async () => fakeClient),
      hasApiToken: jest.fn(),
    };
    app.use('/api/v1/tailscale', routes({
      tailscaleCoord, asyncHandler,
      log: { info: jest.fn(), error: jest.fn(), warn: jest.fn() },
    }));
    const res = await request(app)
      .post('/api/v1/tailscale/settings/test')
      .send({ apiToken: 'arbitrary-junk' });
    expect(res.status).toBe(400);
    expect(fakeClient.setApiToken).not.toHaveBeenCalled();
  });

  test('400 on body.apiToken exceeding length cap (DC-080)', async () => {
    const fakeClient = makeFakeClient();
    const app = express();
    app.use(express.json());
    const routes = require('../../routes/tailscale-admin');
    const tailscaleCoord = {
      loadMetadata: () => ({ configured: false }),
      saveMetadata: jest.fn(),
      setApiToken: jest.fn(),
      getClient: jest.fn(async () => fakeClient),
      hasApiToken: jest.fn(),
    };
    app.use('/api/v1/tailscale', routes({
      tailscaleCoord, asyncHandler,
      log: { info: jest.fn(), error: jest.fn(), warn: jest.fn() },
    }));
    const oversized = 'tskey-api-' + 'x'.repeat(300);
    const res = await request(app)
      .post('/api/v1/tailscale/settings/test')
      .send({ apiToken: oversized });
    expect(res.status).toBe(400);
    expect(fakeClient.setApiToken).not.toHaveBeenCalled();
  });

  test('omitting apiToken is allowed (uses stored token path) (DC-080)', async () => {
    const fakeClient = makeFakeClient({ ping: jest.fn(async () => ({ domain: 'stored.ts.net' })) });
    const app = express();
    app.use(express.json());
    const routes = require('../../routes/tailscale-admin');
    const tailscaleCoord = {
      loadMetadata: () => ({ configured: true }),
      saveMetadata: jest.fn(),
      setApiToken: jest.fn(),
      getClient: jest.fn(async () => fakeClient),
      hasApiToken: jest.fn(),
    };
    app.use('/api/v1/tailscale', routes({
      tailscaleCoord, asyncHandler,
      log: { info: jest.fn(), error: jest.fn(), warn: jest.fn() },
    }));
    const res = await request(app)
      .post('/api/v1/tailscale/settings/test')
      .send({}); // no apiToken in body
    expect(res.status).toBe(200);
    expect(res.body.valid).toBe(true);
  });
});

describe('routes/tailscale-admin: GET /admin/devices', () => {
  test('503 when no token configured', async () => {
    const { app } = createApp({ initialToken: null });
    const res = await request(app).get('/api/v1/tailscale/admin/devices');
    expect(res.status).toBe(503);
  });

  test('returns devices list when configured', async () => {
    const fakeClient = makeFakeClient({
      listDevices: jest.fn(async () => [{ id: 'd1', hostname: 'a' }, { id: 'd2', hostname: 'b' }]),
    });
    const app = express();
    app.use(express.json());
    const routes = require('../../routes/tailscale-admin');
    const tailscaleCoord = {
      loadMetadata: () => ({ configured: true }),
      saveMetadata: jest.fn(),
      setApiToken: jest.fn(),
      getClient: jest.fn(async () => fakeClient),
      hasApiToken: jest.fn(),
    };
    app.use('/api/v1/tailscale', routes({
      tailscaleCoord, asyncHandler,
      log: { info: jest.fn(), error: jest.fn(), warn: jest.fn() },
    }));
    const res = await request(app).get('/api/v1/tailscale/admin/devices');
    expect(res.status).toBe(200);
    expect(res.body.devices).toHaveLength(2);
    expect(res.body.count).toBe(2);
  });

  test('401 when token invalid', async () => {
    const fakeClient = makeFakeClient({
      listDevices: jest.fn(async () => { throw new TailscaleCoordError('unauth', { status: 401, code: 'unauthorized' }); }),
    });
    const app = express();
    app.use(express.json());
    const routes = require('../../routes/tailscale-admin');
    const tailscaleCoord = {
      loadMetadata: () => ({ configured: true }),
      saveMetadata: jest.fn(),
      setApiToken: jest.fn(),
      getClient: jest.fn(async () => fakeClient),
      hasApiToken: jest.fn(),
    };
    app.use('/api/v1/tailscale', routes({
      tailscaleCoord, asyncHandler,
      log: { info: jest.fn(), error: jest.fn(), warn: jest.fn() },
    }));
    const res = await request(app).get('/api/v1/tailscale/admin/devices');
    expect(res.status).toBe(401);
  });
});

describe('routes/tailscale-admin: DELETE /admin/devices/:id', () => {
  test('503 when no token configured', async () => {
    const { app } = createApp({ initialToken: null });
    const res = await request(app).delete('/api/v1/tailscale/admin/devices/d1');
    expect(res.status).toBe(503);
  });

  test('returns success on 200', async () => {
    const fakeClient = makeFakeClient({ deleteDevice: jest.fn(async () => ({ success: true })) });
    const app = express();
    app.use(express.json());
    const routes = require('../../routes/tailscale-admin');
    const tailscaleCoord = {
      loadMetadata: () => ({ configured: true }),
      saveMetadata: jest.fn(),
      setApiToken: jest.fn(),
      getClient: jest.fn(async () => fakeClient),
      hasApiToken: jest.fn(),
    };
    app.use('/api/v1/tailscale', routes({
      tailscaleCoord, asyncHandler,
      log: { info: jest.fn(), error: jest.fn(), warn: jest.fn() },
    }));
    const res = await request(app).delete('/api/v1/tailscale/admin/devices/d1');
    expect(res.status).toBe(200);
    expect(res.body.success).toBe(true);
    expect(fakeClient.deleteDevice).toHaveBeenCalledWith('d1');
  });

  test('404 when device not found', async () => {
    const fakeClient = makeFakeClient({
      deleteDevice: jest.fn(async () => { throw new TailscaleCoordError('not found', { status: 404, code: 'not_found' }); }),
    });
    const app = express();
    app.use(express.json());
    const routes = require('../../routes/tailscale-admin');
    const tailscaleCoord = {
      loadMetadata: () => ({ configured: true }),
      saveMetadata: jest.fn(),
      setApiToken: jest.fn(),
      getClient: jest.fn(async () => fakeClient),
      hasApiToken: jest.fn(),
    };
    app.use('/api/v1/tailscale', routes({
      tailscaleCoord, asyncHandler,
      log: { info: jest.fn(), error: jest.fn(), warn: jest.fn() },
    }));
    const res = await request(app).delete('/api/v1/tailscale/admin/devices/missing');
    expect(res.status).toBe(404);
  });
});

describe('routes/tailscale-admin: GET /admin/users', () => {
  test('returns users list', async () => {
    const fakeClient = makeFakeClient({
      listUsers: jest.fn(async () => [{ id: 'u1', displayName: 'Sami' }, { id: 'u2', displayName: 'Friend' }]),
    });
    const app = express();
    app.use(express.json());
    const routes = require('../../routes/tailscale-admin');
    const tailscaleCoord = {
      loadMetadata: () => ({ configured: true }),
      saveMetadata: jest.fn(),
      setApiToken: jest.fn(),
      getClient: jest.fn(async () => fakeClient),
      hasApiToken: jest.fn(),
    };
    app.use('/api/v1/tailscale', routes({
      tailscaleCoord, asyncHandler,
      log: { info: jest.fn(), error: jest.fn(), warn: jest.fn() },
    }));
    const res = await request(app).get('/api/v1/tailscale/admin/users');
    expect(res.status).toBe(200);
    expect(res.body.users).toHaveLength(2);
  });

  test('503 when not configured', async () => {
    const { app } = createApp({ initialToken: null });
    const res = await request(app).get('/api/v1/tailscale/admin/users');
    expect(res.status).toBe(503);
  });
});

describe('routes/tailscale-admin: pre-auth keys', () => {
  test('GET /admin/keys returns keys list', async () => {
    const fakeClient = makeFakeClient({
      listAuthKeys: jest.fn(async () => [{ id: 'k1', description: 'foo' }]),
    });
    const app = express();
    app.use(express.json());
    const routes = require('../../routes/tailscale-admin');
    const tailscaleCoord = {
      loadMetadata: () => ({ configured: true }),
      saveMetadata: jest.fn(),
      setApiToken: jest.fn(),
      getClient: jest.fn(async () => fakeClient),
      hasApiToken: jest.fn(),
    };
    app.use('/api/v1/tailscale', routes({
      tailscaleCoord, asyncHandler,
      log: { info: jest.fn(), error: jest.fn(), warn: jest.fn() },
    }));
    const res = await request(app).get('/api/v1/tailscale/admin/keys');
    expect(res.status).toBe(200);
    expect(res.body.keys).toHaveLength(1);
    expect(res.body.count).toBe(1);
  });

  test('POST /admin/keys creates a key and returns the secret', async () => {
    const fakeClient = makeFakeClient({
      createAuthKey: jest.fn(async (opts) => ({ id: 'k1', key: 'tskey-auth-secret', ...opts })),
    });
    const app = express();
    app.use(express.json());
    const routes = require('../../routes/tailscale-admin');
    const tailscaleCoord = {
      loadMetadata: () => ({ configured: true }),
      saveMetadata: jest.fn(),
      setApiToken: jest.fn(),
      getClient: jest.fn(async () => fakeClient),
      hasApiToken: jest.fn(),
    };
    app.use('/api/v1/tailscale', routes({
      tailscaleCoord, asyncHandler,
      log: { info: jest.fn(), error: jest.fn(), warn: jest.fn() },
    }));
    const res = await request(app).post('/api/v1/tailscale/admin/keys').send({
      reusable: false,
      ephemeral: true,
      tags: ['tag:guest'],
      description: 'Plex invite',
      expirySeconds: 86400,
    });
    expect(res.status).toBe(200);
    expect(res.body.id).toBe('k1');
    expect(res.body.key).toBe('tskey-auth-secret');
    expect(fakeClient.createAuthKey).toHaveBeenCalledWith(expect.objectContaining({
      tags: ['tag:guest'],
      expirySeconds: 86400,
    }));
  });

  test('POST /admin/keys rejects non-array tags', async () => {
    const fakeClient = makeFakeClient();
    const app = express();
    app.use(express.json());
    const routes = require('../../routes/tailscale-admin');
    const tailscaleCoord = {
      loadMetadata: () => ({ configured: true }),
      saveMetadata: jest.fn(),
      setApiToken: jest.fn(),
      getClient: jest.fn(async () => fakeClient),
      hasApiToken: jest.fn(),
    };
    app.use('/api/v1/tailscale', routes({
      tailscaleCoord, asyncHandler,
      log: { info: jest.fn(), error: jest.fn(), warn: jest.fn() },
    }));
    const res = await request(app).post('/api/v1/tailscale/admin/keys').send({ tags: 'tag:foo' });
    expect(res.status).toBe(400);
  });

  test('POST /admin/keys rejects null/123/object tags entries (DC-080)', async () => {
    const fakeClient = makeFakeClient();
    const app = express();
    app.use(express.json());
    const routes = require('../../routes/tailscale-admin');
    const tailscaleCoord = {
      loadMetadata: () => ({ configured: true }),
      saveMetadata: jest.fn(),
      setApiToken: jest.fn(),
      getClient: jest.fn(async () => fakeClient),
      hasApiToken: jest.fn(),
    };
    app.use('/api/v1/tailscale', routes({
      tailscaleCoord, asyncHandler,
      log: { info: jest.fn(), error: jest.fn(), warn: jest.fn() },
    }));
    // Mixed: null, number, object — all must be rejected
    const res = await request(app).post('/api/v1/tailscale/admin/keys').send({ tags: ['tag:guest', null, 123, { x: 1 }] });
    expect(res.status).toBe(400);
    expect(fakeClient.createAuthKey).not.toHaveBeenCalled();
  });

  test('POST /admin/keys rejects uppercase / whitespace / CRLF in tags (DC-080)', async () => {
    const fakeClient = makeFakeClient();
    const app = express();
    app.use(express.json());
    const routes = require('../../routes/tailscale-admin');
    const tailscaleCoord = {
      loadMetadata: () => ({ configured: true }),
      saveMetadata: jest.fn(),
      setApiToken: jest.fn(),
      getClient: jest.fn(async () => fakeClient),
      hasApiToken: jest.fn(),
    };
    app.use('/api/v1/tailscale', routes({
      tailscaleCoord, asyncHandler,
      log: { info: jest.fn(), error: jest.fn(), warn: jest.fn() },
    }));
    const res = await request(app).post('/api/v1/tailscale/admin/keys').send({ tags: ['TAG:guest', 'tag:foo bar', 'tag:x\r\ninjection'] });
    expect(res.status).toBe(400);
    expect(fakeClient.createAuthKey).not.toHaveBeenCalled();
  });

  test('POST /admin/keys rejects description exceeding 120 chars (DC-080)', async () => {
    const fakeClient = makeFakeClient();
    const app = express();
    app.use(express.json());
    const routes = require('../../routes/tailscale-admin');
    const tailscaleCoord = {
      loadMetadata: () => ({ configured: true }),
      saveMetadata: jest.fn(),
      setApiToken: jest.fn(),
      getClient: jest.fn(async () => fakeClient),
      hasApiToken: jest.fn(),
    };
    app.use('/api/v1/tailscale', routes({
      tailscaleCoord, asyncHandler,
      log: { info: jest.fn(), error: jest.fn(), warn: jest.fn() },
    }));
    const longDesc = 'a'.repeat(200); // > 120 chars
    const res = await request(app).post('/api/v1/tailscale/admin/keys').send({ description: longDesc });
    expect(res.status).toBe(400);
    expect(fakeClient.createAuthKey).not.toHaveBeenCalled();
  });

  test('POST /admin/keys accepts canonical lowercase tag: form (DC-080)', async () => {
    const fakeClient = makeFakeClient({
      createAuthKey: jest.fn(async (opts) => ({ id: 'k2', key: 'tskey-secret-2', ...opts })),
    });
    const app = express();
    app.use(express.json());
    const routes = require('../../routes/tailscale-admin');
    const tailscaleCoord = {
      loadMetadata: () => ({ configured: true }),
      saveMetadata: jest.fn(),
      setApiToken: jest.fn(),
      getClient: jest.fn(async () => fakeClient),
      hasApiToken: jest.fn(),
    };
    app.use('/api/v1/tailscale', routes({
      tailscaleCoord, asyncHandler,
      log: { info: jest.fn(), error: jest.fn(), warn: jest.fn() },
    }));
    const res = await request(app).post('/api/v1/tailscale/admin/keys').send({
      tags: ['tag:guest-plex', 'tag:server'],
      expirySeconds: 86400,
    });
    expect(res.status).toBe(200);
    expect(fakeClient.createAuthKey).toHaveBeenCalledWith(expect.objectContaining({
      tags: ['tag:guest-plex', 'tag:server'],
    }));
  });

  test('POST /admin/keys rejects negative expirySeconds', async () => {
    const fakeClient = makeFakeClient();
    const app = express();
    app.use(express.json());
    const routes = require('../../routes/tailscale-admin');
    const tailscaleCoord = {
      loadMetadata: () => ({ configured: true }),
      saveMetadata: jest.fn(),
      setApiToken: jest.fn(),
      getClient: jest.fn(async () => fakeClient),
      hasApiToken: jest.fn(),
    };
    app.use('/api/v1/tailscale', routes({
      tailscaleCoord, asyncHandler,
      log: { info: jest.fn(), error: jest.fn(), warn: jest.fn() },
    }));
    const res = await request(app).post('/api/v1/tailscale/admin/keys').send({ expirySeconds: -1 });
    expect(res.status).toBe(400);
  });

  test('DELETE /admin/keys/:id returns success', async () => {
    const fakeClient = makeFakeClient({
      deleteAuthKey: jest.fn(async () => ({ success: true })),
    });
    const app = express();
    app.use(express.json());
    const routes = require('../../routes/tailscale-admin');
    const tailscaleCoord = {
      loadMetadata: () => ({ configured: true }),
      saveMetadata: jest.fn(),
      setApiToken: jest.fn(),
      getClient: jest.fn(async () => fakeClient),
      hasApiToken: jest.fn(),
    };
    app.use('/api/v1/tailscale', routes({
      tailscaleCoord, asyncHandler,
      log: { info: jest.fn(), error: jest.fn(), warn: jest.fn() },
    }));
    const res = await request(app).delete('/api/v1/tailscale/admin/keys/k1');
    expect(res.status).toBe(200);
    expect(res.body.success).toBe(true);
    expect(fakeClient.deleteAuthKey).toHaveBeenCalledWith('k1');
  });
});

describe('routes/tailscale-admin: security boundary', () => {
  test('GET /settings never leaks the apiToken field from metadata', async () => {
    const { app } = createApp({
      initialMetadata: { configured: true, tailnetName: 'foo.ts.net', apiToken: 'RAW-LEAK', apiKey: 'LEAK2' },
    });
    const res = await request(app).get('/api/v1/tailscale/settings');
    expect(JSON.stringify(res.body)).not.toContain('RAW-LEAK');
    expect(JSON.stringify(res.body)).not.toContain('LEAK2');
  });

  test('DELETE /settings wipes stored token', async () => {
    const fakeClient = makeFakeClient();
    const { app, stored } = createApp({ initialToken: 'tskey-api-real' });
    await request(app).delete('/api/v1/tailscale/settings');
    expect(stored.token).toBeNull();
  });
});
// DC-080 direct validator unit tests (no supertest, no Express)
describe('routes/tailscale-admin: DC-080 validators (direct)', () => {
  const { _validators } = require('../../routes/tailscale-admin');
  const {
    validateApiToken,
    validateTags,
    validateDescription,
    TAILSCALE_TOKEN_PREFIX,
    TAILSCALE_TOKEN_MAX_LEN,
    DESCRIPTION_MAX_LEN,
  } = _validators;

  describe('validateApiToken', () => {
    test('accepts canonical tskey-api-...', () => {
      expect(validateApiToken('tskey-api-abc123')).toBeNull();
    });
    test('rejects empty', () => {
      expect(validateApiToken('')).toMatch(/required/);
    });
    test('rejects undefined / null', () => {
      expect(validateApiToken(undefined)).toMatch(/required/);
      expect(validateApiToken(null)).toMatch(/required/);
    });
    test('rejects non-string (number, object, array)', () => {
      expect(validateApiToken(123)).toMatch(/must be a string/);
      expect(validateApiToken({})).toMatch(/must be a string/);
      expect(validateApiToken(['x'])).toMatch(/must be a string/);
    });
    test('rejects wrong prefix', () => {
      expect(validateApiToken('not-a-token')).toMatch(/must start with/);
    });
    test('accepts exactly at length cap', () => {
      const token = 'tskey-api-' + 'x'.repeat(TAILSCALE_TOKEN_MAX_LEN - 'tskey-api-'.length);
      expect(validateApiToken(token)).toBeNull();
    });
    test('rejects 1 over length cap', () => {
      const token = 'tskey-api-' + 'x'.repeat(TAILSCALE_TOKEN_MAX_LEN - 'tskey-api-'.length + 1);
      expect(validateApiToken(token)).toMatch(/exceeds maximum length/);
    });
  });

  describe('validateTags', () => {
    test('accepts undefined / null (optional)', () => {
      expect(validateTags(undefined)).toBeNull();
      expect(validateTags(null)).toBeNull();
    });
    test('rejects non-array', () => {
      expect(validateTags('tag:foo')).toMatch(/must be an array/);
      expect(validateTags({})).toMatch(/must be an array/);
    });
    test('rejects entries that are not strings', () => {
      expect(validateTags(['tag:a', null])).toMatch(/tags\[1\]/);
      expect(validateTags(['tag:a', 123])).toMatch(/tags\[1\]/);
      expect(validateTags(['tag:a', {}])).toMatch(/tags\[1\]/);
    });
    test('rejects uppercase / whitespace / CRLF', () => {
      expect(validateTags(['TAG:foo'])).toMatch(/tags\[0\]/);
      expect(validateTags(['tag:foo bar'])).toMatch(/tags\[0\]/);
      expect(validateTags(['tag:foo\r\nbar'])).toMatch(/tags\[0\]/);
    });
    test('rejects entries starting with non-alnum (no leading colon)', () => {
      expect(validateTags([':foo'])).toMatch(/tags\[0\]/);
    });

    test('rejects bare "tag:" with empty name (Tailscale spec violation) (DC-080 round-2)', () => {
      expect(validateTags(['tag:'])).toMatch(/tags\[0\]/);
    });

    test('rejects colon-only chars after tag: prefix (DC-080 round-2)', () => {
      expect(validateTags(['tag:::'])).toMatch(/tags\[0\]/);
      expect(validateTags(['tag:---'])).toMatch(/tags\[0\]/);
    });
    test('accepts canonical tag:server form', () => {
      expect(validateTags(['tag:server'])).toBeNull();
      expect(validateTags(['tag:guest-plex', 'tag:server'])).toBeNull();
    });
    test('rejects empty array entry', () => {
      expect(validateTags(['tag:a', ''])).toMatch(/tags\[1\]/);
    });
  });

  describe('validateDescription', () => {
    test('accepts undefined / null', () => {
      expect(validateDescription(undefined)).toBeNull();
      expect(validateDescription(null)).toBeNull();
    });
    test('rejects non-string', () => {
      expect(validateDescription(123)).toMatch(/must be a string/);
    });
    test('rejects over 120 chars', () => {
      const long = 'a'.repeat(DESCRIPTION_MAX_LEN + 1);
      expect(validateDescription(long)).toMatch(/exceeds maximum length/);
    });
    test('accepts at the cap', () => {
      const exact = 'a'.repeat(DESCRIPTION_MAX_LEN);
      expect(validateDescription(exact)).toBeNull();
    });
  });

  test('exports surface stays in sync with constants used inside validators', () => {
    // Guard against drift: if a future refactor renames a constant, this fails
    expect(TAILSCALE_TOKEN_PREFIX).toBe('tskey-api-');
    expect(typeof TAILSCALE_TOKEN_MAX_LEN).toBe('number');
    expect(typeof DESCRIPTION_MAX_LEN).toBe('number');
  });
});
