import { describe, expect, it, vi } from 'vitest';

import { AppserviceDaemon } from '../server.js';
import type { DaemonConfig, DaemonRequest } from '../server.js';

const AGENTS_TYPE = 'org.uscllc.mosaic_as.agents';

const cfg: DaemonConfig = {
  homeserverUrl: 'https://hs.example',
  domain: 'hs.example',
  asToken: 'as-secret',
  hsToken: 'hs-secret',
  bridgeTokens: ['bridge-secret'],
};

const jsonResponse = (status: number, body: unknown): Response =>
  new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });

const request = (overrides: Partial<DaemonRequest>): DaemonRequest => ({
  method: 'GET',
  path: '/',
  searchParams: new URLSearchParams(),
  body: undefined,
  ...overrides,
});

const makeDaemon = () => {
  const fetchMock = vi.fn(async (_input: URL | string) => jsonResponse(200, { event_id: '$sent' }));
  const daemon = new AppserviceDaemon(cfg, fetchMock as unknown as typeof fetch, () => {});
  return { daemon, fetchMock };
};

describe('AppserviceDaemon routing', () => {
  it('serves health unauthenticated', async () => {
    const { daemon } = makeDaemon();
    expect((await daemon.handle(request({ path: '/health' }))).status).toBe(200);
  });

  it('404s unknown paths', async () => {
    const { daemon } = makeDaemon();
    expect((await daemon.handle(request({ path: '/nope' }))).status).toBe(404);
  });

  it('transactions require the hs_token', async () => {
    const { daemon } = makeDaemon();
    const bad = await daemon.handle(
      request({
        method: 'PUT',
        path: '/_matrix/app/v1/transactions/t1',
        authorizationHeader: 'Bearer wrong',
        body: { events: [] },
      }),
    );
    expect(bad.status).toBe(403);
    const ok = await daemon.handle(
      request({
        method: 'PUT',
        path: '/_matrix/app/v1/transactions/t1',
        authorizationHeader: 'Bearer hs-secret',
        body: { events: [{ type: 'm.room.message', event_id: '$e' }] },
      }),
    );
    expect(ok.status).toBe(200);
  });

  it('bridge requires a bridge token (hs/as tokens do not work)', async () => {
    const { daemon } = makeDaemon();
    for (const token of [undefined, 'Bearer hs-secret', 'Bearer as-secret', 'Bearer nope']) {
      const res = await daemon.handle(
        request({
          method: 'POST',
          path: '/bridge/v1/messages',
          authorizationHeader: token,
          body: {},
        }),
      );
      expect(res.status).toBe(403);
    }
  });

  it('bridge message sends as the agent and returns the event id', async () => {
    const { daemon, fetchMock } = makeDaemon();
    const res = await daemon.handle(
      request({
        method: 'POST',
        path: '/bridge/v1/messages',
        authorizationHeader: 'Bearer bridge-secret',
        body: { room_id: '!r:hs.example', agent: 'pi0-web1', body: 'hi', thread_root: '$req' },
      }),
    );
    expect(res.status).toBe(200);
    expect(res.body.event_id).toBe('$sent');
    const sendCall = fetchMock.mock.calls
      .map((c) => new URL(String(c[0])))
      .find((u) => u.pathname.includes('/send/m.room.message/'));
    expect(sendCall).toBeDefined();
    expect(sendCall!.searchParams.get('user_id')).toBe('@agent-pi0-web1:hs.example');
  });

  it('bridge rejects invalid payloads with 400', async () => {
    const { daemon } = makeDaemon();
    const res = await daemon.handle(
      request({
        method: 'POST',
        path: '/bridge/v1/messages',
        authorizationHeader: 'Bearer bridge-secret',
        body: { room_id: 'bad', agent: 'pi0', body: 'x' },
      }),
    );
    expect(res.status).toBe(400);
  });

  it('bridge typing endpoint works', async () => {
    const { daemon, fetchMock } = makeDaemon();
    const res = await daemon.handle(
      request({
        method: 'POST',
        path: '/bridge/v1/typing',
        authorizationHeader: 'Bearer bridge-secret',
        body: { room_id: '!r:hs.example', agent: 'pi0-web1', typing: true },
      }),
    );
    expect(res.status).toBe(200);
    const typingCall = fetchMock.mock.calls
      .map((c) => new URL(String(c[0])))
      .find((u) => u.pathname.includes('/typing/'));
    expect(typingCall).toBeDefined();
  });

  it('authenticated unknown bridge sub-paths return 405, never fall through', async () => {
    const { daemon } = makeDaemon();
    const res = await daemon.handle(
      request({
        method: 'GET',
        path: '/bridge/v1/unknown',
        authorizationHeader: 'Bearer bridge-secret',
      }),
    );
    expect(res.status).toBe(405);
  });

  it('provisions a room as the AS sender with space linking', async () => {
    const calls: Array<{ url: URL; body: unknown }> = [];
    const fetchMock = vi.fn(async (input: URL | string, init?: RequestInit) => {
      const url = new URL(String(input));
      calls.push({ url, body: init?.body ? JSON.parse(String(init.body)) : undefined });
      if (url.pathname.endsWith('/createRoom'))
        return jsonResponse(200, { room_id: '!new:hs.example' });
      return jsonResponse(200, {});
    });
    const daemon = new AppserviceDaemon(cfg, fetchMock as unknown as typeof fetch, () => {});
    const res = await daemon.handle(
      request({
        method: 'POST',
        path: '/bridge/v1/provision/rooms',
        authorizationHeader: 'Bearer bridge-secret',
        body: {
          name: 'proj-x',
          alias: 'mosaic-proj-x',
          invite: ['@jason.woltje:hs.example'],
          space_id: '!space:hs.example',
        },
      }),
    );
    expect(res.status).toBe(200);
    expect(res.body.room_id).toBe('!new:hs.example');
    expect(res.body.space_linked).toBe(true);
    const create = calls.find((c) => c.url.pathname.endsWith('/createRoom'));
    expect(create!.url.searchParams.get('user_id')).toBe('@mosaic-as:hs.example');
    const body = create!.body as Record<string, unknown>;
    expect(body.room_alias_name).toBe('mosaic-proj-x');
    expect((body.power_level_content_override as Record<string, unknown>).users).toEqual({
      '@mosaic-as:hs.example': 100,
    });
    expect(calls.some((c) => c.url.pathname.includes('/state/m.space.child/'))).toBe(true);
    expect(calls.some((c) => c.url.pathname.includes('/state/m.space.parent/'))).toBe(true);
  });

  it('space-link failure still returns the room id (no orphan)', async () => {
    const fetchMock = vi.fn(async (input: URL | string) => {
      const url = new URL(String(input));
      if (url.pathname.endsWith('/createRoom'))
        return jsonResponse(200, { room_id: '!new:hs.example' });
      if (url.pathname.includes('/state/m.space.child/'))
        return jsonResponse(403, { errcode: 'M_FORBIDDEN', error: 'no PL in space' });
      return jsonResponse(200, {});
    });
    const daemon = new AppserviceDaemon(cfg, fetchMock as unknown as typeof fetch, () => {});
    const res = await daemon.handle(
      request({
        method: 'POST',
        path: '/bridge/v1/provision/rooms',
        authorizationHeader: 'Bearer bridge-secret',
        body: { name: 'proj-x', space_id: '!space:hs.example' },
      }),
    );
    expect(res.status).toBe(200);
    expect(res.body.room_id).toBe('!new:hs.example');
    expect(res.body.space_linked).toBe(false);
    expect(String(res.body.space_error)).toContain('403');
  });

  it('invite list cap enforced', async () => {
    const { daemon } = makeDaemon();
    const res = await daemon.handle(
      request({
        method: 'POST',
        path: '/bridge/v1/provision/rooms',
        authorizationHeader: 'Bearer bridge-secret',
        body: { name: 'x', invite: Array.from({ length: 51 }, (_, i) => `@u${i}:hs`) },
      }),
    );
    expect(res.status).toBe(400);
  });

  it('provision rejects bad payloads and requires auth', async () => {
    const { daemon } = makeDaemon();
    const noAuth = await daemon.handle(
      request({ method: 'POST', path: '/bridge/v1/provision/rooms', body: { name: 'x' } }),
    );
    expect(noAuth.status).toBe(403);
    const bad = await daemon.handle(
      request({
        method: 'POST',
        path: '/bridge/v1/provision/rooms',
        authorizationHeader: 'Bearer bridge-secret',
        body: { name: '', alias: 'BAD ALIAS' },
      }),
    );
    expect(bad.status).toBe(400);
  });

  // A daemon whose fetch mock backs account_data with a mutable in-test object,
  // so register/verify/revoke round-trip through the (faked) homeserver.
  const makeAgentDaemon = () => {
    const accountData: { value: Record<string, unknown> | null } = { value: null };
    const fetchMock = vi.fn(async (input: URL | string, init?: RequestInit) => {
      const url = new URL(String(input));
      const path = url.pathname;
      if (path.includes(`/account_data/${AGENTS_TYPE}`)) {
        if (init?.method === 'PUT') {
          accountData.value = JSON.parse(String(init.body)) as Record<string, unknown>;
          return jsonResponse(200, {});
        }
        if (accountData.value === null) {
          return jsonResponse(404, { errcode: 'M_NOT_FOUND', error: 'not found' });
        }
        return jsonResponse(200, accountData.value);
      }
      if (path.endsWith('/register')) return jsonResponse(200, { user_id: 'whatever' });
      if (path.includes('/send/m.room.message/')) return jsonResponse(200, { event_id: '$sent' });
      return jsonResponse(200, {});
    });
    const daemon = new AppserviceDaemon(cfg, fetchMock as unknown as typeof fetch, () => {});
    return { daemon, fetchMock };
  };

  const registerAgent = async (
    daemon: AppserviceDaemon,
    body: Record<string, unknown> = { alias: 'pi0', host: 'web1' },
  ) =>
    daemon.handle(
      request({
        method: 'POST',
        path: '/bridge/v1/agents',
        authorizationHeader: 'Bearer bridge-secret',
        body,
      }),
    );

  it('host token registers an agent and returns agent_user_id + bridge_token', async () => {
    const { daemon, fetchMock } = makeAgentDaemon();
    const res = await registerAgent(daemon, { alias: 'pi0', host: 'web1' });
    expect(res.status).toBe(200);
    expect(res.body.agent_user_id).toBe('@agent-pi0-web1:hs.example');
    expect(String(res.body.bridge_token).startsWith('magt_')).toBe(true);
    const registerCall = fetchMock.mock.calls
      .map((c) => new URL(String(c[0])))
      .find((u) => u.pathname.endsWith('/register'));
    expect(registerCall).toBeDefined();
  });

  it('register requires a HOST token (agent token and no token are 403)', async () => {
    const { daemon } = makeAgentDaemon();
    const minted = await registerAgent(daemon);
    const agentToken = String(minted.body.bridge_token);

    const asAgent = await daemon.handle(
      request({
        method: 'POST',
        path: '/bridge/v1/agents',
        authorizationHeader: `Bearer ${agentToken}`,
        body: { alias: 'pi1', host: 'web2' },
      }),
    );
    expect(asAgent.status).toBe(403);

    const noAuth = await daemon.handle(
      request({ method: 'POST', path: '/bridge/v1/agents', body: { alias: 'pi1', host: 'web2' } }),
    );
    expect(noAuth.status).toBe(403);
  });

  it('agent-scoped token may send as itself but not as another agent', async () => {
    const { daemon } = makeAgentDaemon();
    const minted = await registerAgent(daemon, { alias: 'pi0', host: 'web1' });
    const agentToken = String(minted.body.bridge_token);

    const self = await daemon.handle(
      request({
        method: 'POST',
        path: '/bridge/v1/messages',
        authorizationHeader: `Bearer ${agentToken}`,
        body: { room_id: '!r:hs.example', agent: 'pi0-web1', body: 'hi' },
      }),
    );
    expect(self.status).toBe(200);

    const other = await daemon.handle(
      request({
        method: 'POST',
        path: '/bridge/v1/messages',
        authorizationHeader: `Bearer ${agentToken}`,
        body: { room_id: '!r:hs.example', agent: 'pi9-web9', body: 'hi' },
      }),
    );
    expect(other.status).toBe(403);
    expect(other.body.error).toBe('token not scoped to this agent');
  });

  it('revoked agent token is rejected on messages', async () => {
    const { daemon } = makeAgentDaemon();
    const minted = await registerAgent(daemon, { alias: 'pi0', host: 'web1' });
    const agentToken = String(minted.body.bridge_token);

    const revoke = await daemon.handle(
      request({
        method: 'POST',
        path: '/bridge/v1/agents/revoke',
        authorizationHeader: 'Bearer bridge-secret',
        body: { agent_user_id: '@agent-pi0-web1:hs.example' },
      }),
    );
    expect(revoke.status).toBe(200);
    expect(revoke.body.revoked).toBe(1);

    const afterRevoke = await daemon.handle(
      request({
        method: 'POST',
        path: '/bridge/v1/messages',
        authorizationHeader: `Bearer ${agentToken}`,
        body: { room_id: '!r:hs.example', agent: 'pi0-web1', body: 'hi' },
      }),
    );
    expect(afterRevoke.status).toBe(403);
  });

  it('GET /bridge/v1/agents lists registered agents (host only)', async () => {
    const { daemon } = makeAgentDaemon();
    await registerAgent(daemon, { alias: 'pi0', host: 'web1', display_name: 'Pi Zero' });

    const res = await daemon.handle(
      request({
        method: 'GET',
        path: '/bridge/v1/agents',
        authorizationHeader: 'Bearer bridge-secret',
      }),
    );
    expect(res.status).toBe(200);
    const agents = res.body.agents as Array<Record<string, unknown>>;
    expect(agents).toHaveLength(1);
    expect(agents[0]?.agent_user_id).toBe('@agent-pi0-web1:hs.example');
    expect(agents[0]?.display_name).toBe('Pi Zero');
  });

  it('empty bridge token list denies everything', async () => {
    const daemon = new AppserviceDaemon({ ...cfg, bridgeTokens: [] }, undefined, () => {});
    const res = await daemon.handle(
      request({
        method: 'POST',
        path: '/bridge/v1/typing',
        authorizationHeader: 'Bearer bridge-secret',
        body: {},
      }),
    );
    expect(res.status).toBe(403);
  });
});
