/**
 * Tests pairing loop guard runtime helpers for channel setup flows.
 */
import { describe, expect, it } from "vitest";
import {
  createPairLoopGuard,
  DEFAULT_PAIR_LOOP_GUARD_SETTINGS,
  mergePairLoopGuardConfig,
  resolvePairLoopGuardSettings,
  type PairLoopGuardSettings,
} from "./pair-loop-guard-runtime.js";

const settings: PairLoopGuardSettings = {
  enabled: true,
  maxEventsPerWindow: 3,
  windowMs: 60_000,
  cooldownMs: 5_000,
};

describe("createPairLoopGuard", () => {
  it("suppresses either direction once a participant pair exceeds the window budget", () => {
    const guard = createPairLoopGuard();
    const base = { scopeId: "scope-1", conversationId: "conversation-1", settings };

    expect(
      guard.recordAndCheck({
        ...base,
        senderId: "participant-a",
        receiverId: "participant-b",
        nowMs: 1_000,
      }),
    ).toEqual({ suppressed: false });
    expect(
      guard.recordAndCheck({
        ...base,
        senderId: "participant-b",
        receiverId: "participant-a",
        nowMs: 1_010,
      }),
    ).toEqual({ suppressed: false });
    expect(
      guard.recordAndCheck({
        ...base,
        senderId: "participant-a",
        receiverId: "participant-b",
        nowMs: 1_020,
      }),
    ).toEqual({ suppressed: false });

    const result = guard.recordAndCheck({
      ...base,
      senderId: "participant-b",
      receiverId: "participant-a",
      nowMs: 1_030,
    });

    expect(result).toEqual({ suppressed: true, cooldownUntilMs: 1_030 + settings.cooldownMs });
  });

  it("keeps scopes and conversations independent", () => {
    const guard = createPairLoopGuard();
    const base = {
      scopeId: "scope-1",
      conversationId: "conversation-1",
      senderId: "participant-a",
      receiverId: "participant-b",
      settings,
    };

    for (let index = 0; index < settings.maxEventsPerWindow + 1; index += 1) {
      guard.recordAndCheck({ ...base, nowMs: 1_000 + index });
    }

    expect(guard.recordAndCheck({ ...base, conversationId: "conversation-2" })).toEqual({
      suppressed: false,
    });
    expect(guard.recordAndCheck({ ...base, scopeId: "scope-2" })).toEqual({ suppressed: false });
  });

  it("prunes inactive pair entries opportunistically", () => {
    const guard = createPairLoopGuard();
    const base = { scopeId: "scope-1", conversationId: "conversation-1", settings };

    guard.recordAndCheck({
      ...base,
      senderId: "participant-a",
      receiverId: "participant-b",
      nowMs: 1_000,
    });
    expect(guard.snapshot()).toHaveLength(1);

    guard.recordAndCheck({
      ...base,
      senderId: "participant-c",
      receiverId: "participant-d",
      nowMs: 61_001,
    });

    const trackedPairs = guard.snapshot();
    expect(trackedPairs).toHaveLength(1);
    expect(trackedPairs[0]?.key).toContain("participant-c");
    expect(trackedPairs[0]?.key).toContain("participant-d");
  });

  it("uses each tracked pair's own window when pruning inactive entries", () => {
    const guard = createPairLoopGuard();
    const longWindowSettings = { ...settings, windowMs: 120_000 };

    guard.recordAndCheck({
      scopeId: "scope-1",
      conversationId: "conversation-1",
      senderId: "participant-a",
      receiverId: "participant-b",
      settings: longWindowSettings,
      nowMs: 1_000,
    });
    guard.recordAndCheck({
      scopeId: "scope-1",
      conversationId: "conversation-1",
      senderId: "participant-c",
      receiverId: "participant-d",
      settings,
      nowMs: 61_001,
    });

    expect(guard.snapshot()).toHaveLength(2);
  });

  it("does not count future event timestamps against older reordered events", () => {
    const guard = createPairLoopGuard();
    const strictSettings = { ...settings, maxEventsPerWindow: 1 };
    const base = {
      scopeId: "scope-1",
      conversationId: "conversation-1",
      senderId: "participant-a",
      receiverId: "participant-b",
      settings: strictSettings,
    };

    expect(guard.recordAndCheck({ ...base, nowMs: 120_000 })).toEqual({ suppressed: false });
    expect(guard.recordAndCheck({ ...base, nowMs: 0 })).toEqual({ suppressed: false });
    expect(guard.recordAndCheck({ ...base, nowMs: 120_500 })).toEqual({
      suppressed: true,
      cooldownUntilMs: 120_500 + strictSettings.cooldownMs,
    });
  });

  it("does not apply a future cooldown to an older reordered event", () => {
    const guard = createPairLoopGuard();
    const strictSettings = { ...settings, maxEventsPerWindow: 1 };
    const base = {
      scopeId: "scope-1",
      conversationId: "conversation-1",
      senderId: "participant-a",
      receiverId: "participant-b",
      settings: strictSettings,
    };

    expect(guard.recordAndCheck({ ...base, nowMs: 120_000 })).toEqual({ suppressed: false });
    expect(guard.recordAndCheck({ ...base, nowMs: 120_500 })).toEqual({
      suppressed: true,
      cooldownUntilMs: 120_500 + strictSettings.cooldownMs,
    });
    expect(guard.recordAndCheck({ ...base, nowMs: 0 })).toEqual({ suppressed: false });
  });

  it("does not track disabled, invalid, or self-pair events", () => {
    const guard = createPairLoopGuard();
    const base = {
      scopeId: "scope-1",
      conversationId: "conversation-1",
      senderId: "participant-a",
      receiverId: "participant-b",
      settings,
    };

    expect(guard.recordAndCheck({ ...base, settings: { ...settings, enabled: false } })).toEqual({
      suppressed: false,
    });
    expect(guard.recordAndCheck({ ...base, conversationId: "" })).toEqual({ suppressed: false });
    expect(guard.recordAndCheck({ ...base, receiverId: "participant-a" })).toEqual({
      suppressed: false,
    });
    expect(guard.snapshot()).toEqual([]);
  });
});

describe("mergePairLoopGuardConfig", () => {
  it("layers partial child config over parent config field-by-field", () => {
    expect(
      mergePairLoopGuardConfig(
        { enabled: true, maxEventsPerWindow: 8, windowSeconds: 120, cooldownSeconds: 30 },
        { maxEventsPerWindow: 2 },
      ),
    ).toEqual({
      enabled: true,
      maxEventsPerWindow: 2,
      windowSeconds: 120,
      cooldownSeconds: 30,
    });
  });

  it("preserves explicit false and ignores undefined override fields", () => {
    expect(mergePairLoopGuardConfig({ enabled: false }, { windowSeconds: undefined })).toEqual({
      enabled: false,
    });
    expect(mergePairLoopGuardConfig(undefined, undefined)).toBeUndefined();
  });
});

describe("resolvePairLoopGuardSettings", () => {
  it("uses built-in channel loop guard defaults when no config is set", () => {
    expect(resolvePairLoopGuardSettings({ defaultEnabled: true })).toEqual(
      DEFAULT_PAIR_LOOP_GUARD_SETTINGS,
    );
  });

  it("keeps the guard disabled when the channel has no bot-to-bot path", () => {
    expect(resolvePairLoopGuardSettings({ defaultEnabled: false }).enabled).toBe(false);
  });

  it("lets channel config override shared channel defaults field-by-field", () => {
    const resolved = resolvePairLoopGuardSettings({
      config: { maxEventsPerWindow: 4, windowSeconds: 10 },
      defaultsConfig: { maxEventsPerWindow: 8, windowSeconds: 120, cooldownSeconds: 30 },
      defaultEnabled: true,
    });

    expect(resolved).toEqual({
      enabled: true,
      maxEventsPerWindow: 4,
      windowMs: 10_000,
      cooldownMs: 30_000,
    });
  });

  it("honors enabled=false from either channel or shared defaults", () => {
    expect(
      resolvePairLoopGuardSettings({
        config: { enabled: false },
        defaultsConfig: { enabled: true },
        defaultEnabled: true,
      }).enabled,
    ).toBe(false);
    expect(
      resolvePairLoopGuardSettings({
        defaultsConfig: { enabled: false },
        defaultEnabled: true,
      }).enabled,
    ).toBe(false);
  });

  it("falls back to built-in defaults for invalid numeric config", () => {
    expect(
      resolvePairLoopGuardSettings({
        config: { maxEventsPerWindow: 0, windowSeconds: -1, cooldownSeconds: -5 },
        defaultEnabled: true,
      }),
    ).toEqual(DEFAULT_PAIR_LOOP_GUARD_SETTINGS);
  });
});
