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

import { FleetLivenessReader } from '../liveness-reader.js';
import type { MinimalMatrixClient } from '../matrix-client.js';
import { PresenceAgent } from '../presence-agent.js';
import type { HeartbeatObservation, LivenessPolicy, PresenceStatus } from '../types.js';

/**
 * An in-memory fake homeserver room: records heartbeats with a controllable
 * server clock and reduces them exactly like the real readHeartbeats. Lets us
 * prove the PresenceAgent -> room -> FleetLivenessReader flow (including the A3
 * hard-kill -> offline transition) deterministically, with no network.
 */
class FakeRoomClient {
  readonly beats: Array<{
    slug: string;
    mxid: string;
    seq: number;
    ts: number;
    status: PresenceStatus;
  }> = [];
  presence: Record<string, PresenceStatus> = {};

  constructor(private readonly clock: () => number) {}

  async joinRoom(roomId: string): Promise<string> {
    return roomId;
  }
  async setPresence(userId: string, status: PresenceStatus): Promise<void> {
    this.presence[userId] = status;
  }
  async sendHeartbeat(
    _roomId: string,
    content: { agent: { slug: string; mxid: string }; seq: number; status: PresenceStatus },
  ): Promise<string> {
    this.beats.push({
      slug: content.agent.slug,
      mxid: content.agent.mxid,
      seq: content.seq,
      ts: this.clock(), // server receive time
      status: content.status,
    });
    return `$evt${this.beats.length}`;
  }
  async readHeartbeats(): Promise<HeartbeatObservation[]> {
    const bySlug = new Map<string, HeartbeatObservation>();
    for (const b of this.beats) {
      const prev = bySlug.get(b.slug);
      if (!prev || b.seq > prev.lastSeq) {
        bySlug.set(b.slug, {
          slug: b.slug,
          mxid: b.mxid,
          lastSeenTs: b.ts,
          lastSeq: b.seq,
          assertedStatus: b.status,
        });
      }
    }
    return [...bySlug.values()];
  }
}

const policy: LivenessPolicy = {
  heartbeatIntervalMs: 1000,
  missTolerance: 2,
  darkThresholdMs: 5000,
};

describe('presence flow (A2 + A3 at unit level)', () => {
  afterEach(() => vi.useRealTimers());

  it('shows agents online while beating, then A3: a hard-killed agent goes offline within dark_threshold', async () => {
    vi.useFakeTimers();
    vi.setSystemTime(0);

    const fake = new FakeRoomClient(() => Date.now());
    const client = fake as unknown as MinimalMatrixClient;
    const reader = new FleetLivenessReader({
      client,
      roomId: '!fleet',
      policy,
      now: () => Date.now(),
    });

    const mk = (slug: string) =>
      new PresenceAgent({
        client,
        agent: { mxid: `@agent-${slug}:matrix.localhost`, slug, harness: 'claude-code' },
        roomId: '!fleet',
        intervalMs: 1000,
        policy,
      });

    const alpha = mk('alpha');
    const bravo = mk('bravo');
    const charlie = mk('charlie');

    for (const a of [alpha, bravo, charlie]) {
      await a.connect();
      a.start();
    }
    // native presence set online for all three (Element dot)
    expect(fake.presence['@agent-alpha:matrix.localhost']).toBe('online');

    // let a couple of beats flow — all three fresh => online (A2)
    await vi.advanceTimersByTimeAsync(1500);
    const board1 = Object.fromEntries((await reader.read()).map((r) => [r.slug, r.status]));
    expect(board1).toEqual({ alpha: 'online', bravo: 'online', charlie: 'online' });

    // HARD-KILL charlie: stop its loop, no more beats. alpha/bravo keep beating.
    charlie.pauseHeartbeat(); // hard-kill: no graceful presence signal

    // advance to just before dark threshold from charlie's last beat...
    await vi.advanceTimersByTimeAsync(3000);
    const mid = Object.fromEntries((await reader.read()).map((r) => [r.slug, r.status]));
    expect(mid.alpha).toBe('online');
    expect(mid.charlie).not.toBe('online'); // already stale (away)

    // ...advance past dark_threshold: charlie is deterministically offline.
    await vi.advanceTimersByTimeAsync(4000);
    const final = await reader.read();
    const byslug = Object.fromEntries(final.map((r) => [r.slug, r]));
    expect(byslug.charlie!.status).toBe('offline');
    expect(byslug.alpha!.status).toBe('online');
    expect(byslug.bravo!.status).toBe('online');

    for (const a of [alpha, bravo]) await a.stop();
  });

  it('formatBoard renders a human-readable liveness board (A4)', async () => {
    const fake = new FakeRoomClient(() => 10_000);
    fake.beats.push({
      slug: 'alpha',
      mxid: '@agent-alpha:matrix.localhost',
      seq: 3,
      ts: 9_500,
      status: 'online',
    });
    const reader = new FleetLivenessReader({
      client: fake as unknown as MinimalMatrixClient,
      roomId: '!fleet',
      policy,
      now: () => 10_000,
    });
    const board = await reader.formatBoard();
    expect(board).toContain('Fleet presence');
    expect(board).toContain('alpha');
    expect(board).toContain('online');
    expect(board).toContain('online=1');
  });
});
