import { describe, expect, test } from 'bun:test';
import {
  CAPABILITIES,
  CAPABILITY_BY_KEY,
  GRANTABLE_CAPABILITIES,
  capabilityForApiPath,
  capabilityForWsProvider,
  isRequestAllowedAtLevel,
  isSelfServiceRoute,
} from './registry';
import { assertCapabilityTotality, isExemptApiPath } from './totality';

// The database-backed half (authorize.ts) is exercised against the live schema; this file covers the pure
// half, which is where the rules actually live. Everything here runs without a database.

const REAL_WS = [
  'terminal',
  'chat',
  'task-runner',
  'pipeline',
  'cliamp',
  'cliamp-audio',
  'desktop',
  'vault',
  'sidecar',
];
const realApi = () => [...new Set(CAPABILITIES.flatMap((c) => c.api))];
const surface = () => ({
  apiPrefixes: [...realApi(), '/auth', '/landing-page-data', '/waitlist', '/vault', '/sidecar'],
  wsProviders: REAL_WS,
});

describe('totality', () => {
  test('the registry covers its own declared surface', () => {
    expect(() => assertCapabilityTotality(surface())).not.toThrow();
  });

  // The four ways this is allowed to fail. Each one is a real bug it exists to catch, and a check that
  // only ever passes is worth nothing — so assert that it refuses, not merely that it runs.
  test('refuses a mounted router no capability claims', () => {
    const s = surface();
    s.apiPrefixes.push('/newthing');
    expect(() => assertCapabilityTotality(s)).toThrow(/\/api\/newthing is mounted but no capability claims it/);
  });

  test('refuses a served socket no capability claims — the 2026-08-06 hole', () => {
    const s = surface();
    s.wsProviders = [...REAL_WS, 'newsocket'];
    expect(() => assertCapabilityTotality(s)).toThrow(/websocket provider 'newsocket' is served/);
  });

  test('refuses a claim on a router that no longer exists', () => {
    const s = surface();
    s.apiPrefixes = s.apiPrefixes.filter((p) => p !== '/gitea');
    expect(() => assertCapabilityTotality(s)).toThrow(/claims \/api\/gitea, which nothing mounts/);
  });

  test('refuses a claim on a socket that is not served', () => {
    const s = surface();
    s.wsProviders = REAL_WS.filter((p) => p !== 'cliamp');
    expect(() => assertCapabilityTotality(s)).toThrow(/claims websocket 'cliamp', which is not served/);
  });

  test('no two capabilities claim the same prefix', () => {
    const seen = new Map<string, string>();
    for (const capability of CAPABILITIES) {
      for (const prefix of capability.api) {
        expect(seen.has(prefix)).toBe(false);
        seen.set(prefix, capability.key);
      }
    }
  });

  test('signin is exempt and gitea is not', () => {
    expect(isExemptApiPath('/api/auth/signin')).toBe(true);
    expect(isExemptApiPath('/api/gitea')).toBe(false);
    expect(isExemptApiPath('/api/music/albums')).toBe(false);
  });
});

describe('path → capability', () => {
  test('resolves a prefix and its descendants', () => {
    expect(capabilityForApiPath('/api/gitea')?.key).toBe('gitea');
    expect(capabilityForApiPath('/api/gitea/repos/a/b')?.key).toBe('gitea');
  });

  test('does not match a prefix that is merely a string prefix', () => {
    // '/api/musicbrainz' must not resolve to the 'music' capability. A naive startsWith would.
    expect(capabilityForApiPath('/api/musicbrainz')).toBeNull();
  });

  test('longest prefix wins, so /dav and /caldav do not fight', () => {
    expect(capabilityForApiPath('/api/caldav/x')?.key).toBe('calendar');
    expect(capabilityForApiPath('/api/dav/x')?.key).toBe('calendar');
  });

  test('an unclaimed path resolves to nothing rather than to something permissive', () => {
    expect(capabilityForApiPath('/api/not-a-thing')).toBeNull();
  });

  test('sockets resolve to their capability', () => {
    expect(capabilityForWsProvider('cliamp')?.key).toBe('music');
    expect(capabilityForWsProvider('terminal')?.key).toBe('terminal');
    expect(capabilityForWsProvider('nope')).toBeNull();
  });
});

describe('levels', () => {
  const music = CAPABILITY_BY_KEY.get('music')!;

  test('write permits anything within the capability', () => {
    expect(isRequestAllowedAtLevel(music, 'write', 'DELETE', '/api/music/track/9')).toBe(true);
  });

  test('read permits safe methods', () => {
    expect(isRequestAllowedAtLevel(music, 'read', 'GET', '/api/music/albums')).toBe(true);
    expect(isRequestAllowedAtLevel(music, 'read', 'HEAD', '/api/music/albums')).toBe(true);
  });

  test('read permits mutations only under personal sub-paths', () => {
    expect(isRequestAllowedAtLevel(music, 'read', 'POST', '/api/music/favorites/7')).toBe(true);
    expect(isRequestAllowedAtLevel(music, 'read', 'PUT', '/api/music/now-playing')).toBe(true);
    expect(isRequestAllowedAtLevel(music, 'read', 'POST', '/api/music/scan')).toBe(false);
    expect(isRequestAllowedAtLevel(music, 'read', 'DELETE', '/api/music/track/9')).toBe(false);
  });

  test('a personal entry does not leak across a name boundary', () => {
    // '/favorites-export' must not be covered by the '/favorites' personal entry.
    expect(isRequestAllowedAtLevel(music, 'read', 'POST', '/api/music/favorites-export')).toBe(false);
  });
});

describe('self-service routes', () => {
  const userAdmin = CAPABILITY_BY_KEY.get('user-admin')!;

  test('PUT /api/users is self-profile update and stays reachable', () => {
    expect(isSelfServiceRoute(userAdmin, 'PUT', '/api/users')).toBe(true);
    expect(isSelfServiceRoute(userAdmin, 'put', '/api/users')).toBe(true);
  });

  test('it does not open the rest of the router', () => {
    expect(isSelfServiceRoute(userAdmin, 'GET', '/api/users')).toBe(false);
    expect(isSelfServiceRoute(userAdmin, 'DELETE', '/api/users/5')).toBe(false);
    expect(isSelfServiceRoute(userAdmin, 'PATCH', '/api/users/5/role')).toBe(false);
    // The exact-match rule: a descendant of a self-service path is not self-service.
    expect(isSelfServiceRoute(userAdmin, 'PUT', '/api/users/5')).toBe(false);
  });

  test('capabilities without the field are unaffected', () => {
    expect(isSelfServiceRoute(CAPABILITY_BY_KEY.get('wallet')!, 'PUT', '/api/wallet')).toBe(false);
  });
});

describe('kinds', () => {
  test('execution capabilities are never grantable', () => {
    const grantable = new Set(GRANTABLE_CAPABILITIES.map((c) => c.key));
    for (const key of ['terminal', 'chat', 'files', 'tasks', 'desktop', 'browser', 'items']) {
      expect(CAPABILITY_BY_KEY.get(key)?.kind).toBe('execution');
      expect(grantable.has(key)).toBe(false);
    }
  });

  test('admin capabilities are never grantable', () => {
    const grantable = new Set(GRANTABLE_CAPABILITIES.map((c) => c.key));
    for (const key of ['user-admin', 'server-admin', 'wallet', 'headscale']) {
      expect(CAPABILITY_BY_KEY.get(key)?.kind).toBe('admin');
      expect(grantable.has(key)).toBe(false);
    }
  });

  test('gitea is grantable — the case this was built for', () => {
    expect(GRANTABLE_CAPABILITIES.some((c) => c.key === 'gitea')).toBe(true);
  });

  test('every capability declares at least one api prefix', () => {
    for (const capability of CAPABILITIES) expect(capability.api.length).toBeGreaterThan(0);
  });
});
