import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";

import { BaseClient } from "../src/base_client.js";
import { parserFromSchema } from "../src/communicator.js";
import {
  create,
  HeartBeatSchema,
  TestDataSchema,
  toBinary,
  type PacketBase,
} from "../src/packets/message_common_pb.js";

class TestClient extends BaseClient {
  closeCalls = 0;
  packets: PacketBase[] = [];

  constructor(intervalSec: number, waitSec: number) {
    super(intervalSec, waitSec, async () => {
      this.closeCalls += 1;
    });
    this.initBase(new Map([[TestDataSchema.typeName, parserFromSchema(TestDataSchema)]]));
  }

  async writePacket(base: PacketBase): Promise<void> {
    this.packets.push(base);
  }
}

class FailingCloseClient extends BaseClient {
  constructor() {
    super(0, 0, async () => {
      throw new Error("close failed");
    });
    this.initBase(new Map([[TestDataSchema.typeName, parserFromSchema(TestDataSchema)]]));
  }

  async writePacket(_base: PacketBase): Promise<void> {}

  async triggerDisconnect(): Promise<void> {
    await this.onDisconnected();
  }
}

describe("BaseClient", () => {
  beforeEach(() => {
    vi.useFakeTimers();
  });

  afterEach(() => {
    vi.useRealTimers();
  });

  test("close is idempotent", async () => {
    const client = new TestClient(0, 0);

    await Promise.all([client.close(), client.close(), client.close()]);

    expect(client.closeCalls).toBe(1);
  });

  test("disconnect listeners called on close", async () => {
    const client = new TestClient(0, 0);
    const listener = vi.fn(async () => {});
    client.addDisconnectListener(listener);

    await client.close();

    expect(listener).toHaveBeenCalledTimes(1);
  });

  test("disconnect listener failure does not block remaining listeners", async () => {
    const client = new TestClient(0, 0);
    const first = vi.fn(async () => {
      throw new Error("listener failed");
    });
    const second = vi.fn(async () => {});
    client.addDisconnectListener(first);
    client.addDisconnectListener(second);

    await expect(client.close()).resolves.toBeUndefined();

    expect(first).toHaveBeenCalledTimes(1);
    expect(second).toHaveBeenCalledTimes(1);
  });

  test("heartbeat sends HeartBeat after interval", async () => {
    const client = new TestClient(2, 1);

    await vi.advanceTimersByTimeAsync(2000);

    expect(client.packets).toHaveLength(1);
    expect(client.packets[0]?.typeName).toBe(HeartBeatSchema.typeName);
  });

  test("heartbeat timeout triggers disconnect", async () => {
    const client = new TestClient(2, 1);

    await vi.advanceTimersByTimeAsync(3000);

    expect(client.closeCalls).toBe(1);
  });

  test("onHeartbeat resets waiting flag", async () => {
    const client = new TestClient(2, 1);

    await vi.advanceTimersByTimeAsync(2000);
    client.communicator.onReceivedData(
      HeartBeatSchema.typeName,
      toBinary(HeartBeatSchema, create(HeartBeatSchema)),
    );
    await vi.advanceTimersByTimeAsync(0);
    expect(client.packets).toHaveLength(1);

    client.communicator.onReceivedData(
      HeartBeatSchema.typeName,
      toBinary(HeartBeatSchema, create(HeartBeatSchema)),
    );
    await vi.advanceTimersByTimeAsync(0);

    expect(client.packets).toHaveLength(2);
    expect(client.packets[1]?.typeName).toBe(HeartBeatSchema.typeName);
  });

  test("onDisconnected swallows internal close errors", async () => {
    const client = new FailingCloseClient();

    await expect(client.triggerDisconnect()).resolves.toBeUndefined();
  });
});
