import { describe, expect, test } from "vitest";
import {
  attachDashboardRefreshLifecycle,
  collectVisibleDashboardModelIds,
  createDashboardPerformanceMarks,
  createDashboardRefreshDelay,
  dashboardPerformanceMarks,
  createDashboardTileSnapshotStore,
  createDashboardTileBackoff,
  createDashboardRequestAborter,
  runDashboardHydrationQueue,
  runDashboardHydrationBatchQueue,
  runViewportAwareDashboardHydrationQueue,
  shouldPauseDashboardRefresh,
  splitDashboardHydrationItemsByVisibility,
  waitForDashboardHydrationIdle,
  type DashboardIntersectionEntry,
} from "./dashboard-refresh";

describe("dashboard refresh lifecycle", () => {
  test("pauses refreshes when the document is hidden or the browser is offline", () => {
    expect(
      shouldPauseDashboardRefresh({ visibilityState: "visible", online: true }),
    ).toBe(false);
    expect(
      shouldPauseDashboardRefresh({ visibilityState: "hidden", online: true }),
    ).toBe(true);
    expect(
      shouldPauseDashboardRefresh({ visibilityState: "visible", online: false }),
    ).toBe(true);
  });

  test("aborts shell and tile requests when a new shell run starts", () => {
    const aborter = createDashboardRequestAborter();
    const shellSignal = aborter.beginShellRun();
    const tileSignal = aborter.beginTileRun();

    const nextShellSignal = aborter.beginShellRun();

    expect(shellSignal.aborted).toBe(true);
    expect(tileSignal.aborted).toBe(true);
    expect(nextShellSignal.aborted).toBe(false);
  });

  test("aborts active requests when refreshes are paused", () => {
    const aborter = createDashboardRequestAborter();
    const shellSignal = aborter.beginShellRun();
    const tileSignal = aborter.beginTileRun();

    aborter.abortActiveRequests();

    expect(shellSignal.aborted).toBe(true);
    expect(tileSignal.aborted).toBe(true);
  });

  test("pauses on hidden, offline, and pagehide events then resumes immediately when visible", () => {
    const documentTarget = new EventTarget();
    const windowTarget = new EventTarget();
    const calls: string[] = [];
    let paused = true;

    const detach = attachDashboardRefreshLifecycle({
      documentTarget,
      windowTarget,
      loadDashboard: () => {
        calls.push("load");
      },
      pauseRefreshes: () => {
        calls.push("pause");
      },
      refreshPaused: () => paused,
    });

    documentTarget.dispatchEvent(new Event("visibilitychange"));
    windowTarget.dispatchEvent(new Event("offline"));
    windowTarget.dispatchEvent(new Event("pagehide"));

    paused = false;
    documentTarget.dispatchEvent(new Event("visibilitychange"));
    windowTarget.dispatchEvent(new Event("online"));

    detach();
    documentTarget.dispatchEvent(new Event("visibilitychange"));
    windowTarget.dispatchEvent(new Event("online"));

    expect(calls).toEqual(["pause", "pause", "pause", "load", "load"]);
  });

  test("limits tile hydration concurrency", async () => {
    let active = 0;
    let maxActive = 0;
    const started: number[] = [];
    const releases = new Map<number, () => void>();

    const queue = runDashboardHydrationQueue({
      concurrency: 2,
      items: [1, 2, 3, 4],
      signal: new AbortController().signal,
      hydrate: async (item) => {
        active += 1;
        maxActive = Math.max(maxActive, active);
        started.push(item);
        await new Promise<void>((resolve) => releases.set(item, resolve));
        active -= 1;
      },
    });

    await waitFor(() => started.length === 2);
    expect(started).toEqual([1, 2]);
    expect(maxActive).toBe(2);

    releases.get(1)?.();
    await waitFor(() => started.length === 3);
    expect(started).toEqual([1, 2, 3]);
    expect(maxActive).toBe(2);

    releases.get(2)?.();
    releases.get(3)?.();
    await waitFor(() => started.length === 4);
    releases.get(4)?.();
    await queue;
    expect(maxActive).toBe(2);
  });

  test("hydrates queued work in fixed-size batches", async () => {
    const batches: number[][] = [];

    await runDashboardHydrationBatchQueue({
      batchSize: 3,
      hydrateBatch: (items) => {
        batches.push(items);
      },
      items: [1, 2, 3, 4, 5, 6, 7],
      signal: new AbortController().signal,
    });

    expect(batches).toEqual([
      [1, 2, 3],
      [4, 5, 6],
      [7],
    ]);
  });

  test("hydrates visible items before deferred items and waits for idle", async () => {
    const calls: string[] = [];
    const idleReleases: Array<() => void> = [];

    const hydration = runViewportAwareDashboardHydrationQueue({
      collectVisibleModelIds: async () => new Set(["b"]),
      concurrency: 1,
      getModelId: (item) => item,
      hydrate: async (item) => {
        calls.push(item);
      },
      items: ["a", "b", "c"],
      onAllItemsSettled: () => calls.push("all-settled"),
      onVisibleItemsSettled: () => calls.push("visible-settled"),
      signal: new AbortController().signal,
      waitForIdle: async () => {
        calls.push("idle");
        await new Promise<void>((resolve) => idleReleases.push(resolve));
      },
    });

    await waitFor(() => calls.includes("idle"));
    expect(calls).toEqual(["b", "visible-settled", "idle"]);

    idleReleases.shift()?.();
    await hydration;
    expect(calls).toEqual([
      "b",
      "visible-settled",
      "idle",
      "a",
      "c",
      "all-settled",
    ]);
  });

  test("splits visible hydration items while preserving document order", () => {
    expect(splitDashboardHydrationItemsByVisibility({
      getModelId: (item) => item.id,
      items: [{ id: "status" }, { id: "telemetry" }, { id: "service" }],
      visibleModelIds: new Set(["service", "status"]),
    })).toEqual({
      visible: [{ id: "status" }, { id: "service" }],
      deferred: [{ id: "telemetry" }],
    });
  });

  test("collects visible data-model-id elements with IntersectionObserver", async () => {
    const elements = [
      modelElement("status"),
      modelElement("telemetry"),
      modelElement("unrelated"),
    ];
    let callback:
      | ((entries: DashboardIntersectionEntry[]) => void)
      | undefined;
    let finishObservation: (() => void) | undefined;
    let disconnected = false;
    const observed: string[] = [];

    const visible = collectVisibleDashboardModelIds({
      createObserver: (observerCallback) => {
        callback = observerCallback;
        return {
          disconnect() {
            disconnected = true;
          },
          observe(element) {
            observed.push(element.getAttribute("data-model-id") || "");
          },
        };
      },
      documentTarget: {
        querySelectorAll: () => elements,
      },
      modelIds: ["status", "telemetry"],
      setTimeout: (handler) => {
        finishObservation = handler;
        return 1 as unknown as ReturnType<typeof setTimeout>;
      },
      clearTimeout: () => undefined,
      signal: new AbortController().signal,
    });

    callback?.([
      {
        isIntersecting: false,
        intersectionRatio: 0,
        target: elements[0],
      },
      {
        isIntersecting: true,
        target: elements[1],
      },
    ]);
    finishObservation?.();

    expect(await visible).toEqual(new Set(["telemetry"]));
    expect(observed).toEqual(["status", "telemetry"]);
    expect(disconnected).toBe(true);
  });

  test("waits for requestIdleCallback when available", async () => {
    let idleCallback: (() => void) | undefined;
    let cancelledIdle: number | undefined;
    const wait = waitForDashboardHydrationIdle({
      cancelIdleCallback: (handle) => {
        cancelledIdle = handle;
      },
      requestIdleCallback: (callback) => {
        idleCallback = callback;
        return 7;
      },
      signal: new AbortController().signal,
    });

    expect(cancelledIdle).toBeUndefined();
    idleCallback?.();
    await wait;
    expect(cancelledIdle).toBe(7);
  });

  test("jitters refresh delays and slows repeated failures", () => {
    const delay = createDashboardRefreshDelay({
      jitterRatio: 0.1,
      random: () => 1,
    });

    expect(delay.nextDelayMs(1_000)).toBe(1_100);
    delay.recordFailure();
    expect(delay.nextDelayMs(1_000)).toBe(2_200);
    delay.recordFailure();
    expect(delay.nextDelayMs(1_000)).toBe(4_400);
    delay.recordSuccess();
    expect(delay.nextDelayMs(1_000)).toBe(1_100);
  });

  test("marks dashboard performance milestones once per shell run", () => {
    const marks: string[] = [];
    const performanceMarks = createDashboardPerformanceMarks({
      mark: (name) => marks.push(name),
    });

    performanceMarks.markShellLoad();
    performanceMarks.markFirstTileReady();
    performanceMarks.markFirstTileReady();
    performanceMarks.markVisibleTilesReady();
    performanceMarks.markAllTilesSettled();
    performanceMarks.markShellLoad();
    performanceMarks.markFirstTileReady();

    expect(marks).toEqual([
      dashboardPerformanceMarks.shellLoad,
      dashboardPerformanceMarks.firstTileReady,
      dashboardPerformanceMarks.visibleTilesReady,
      dashboardPerformanceMarks.allTilesSettled,
      dashboardPerformanceMarks.shellLoad,
      dashboardPerformanceMarks.firstTileReady,
    ]);
  });

  test("backs off failed tile keys and resets after success", () => {
    const backoff = createDashboardTileBackoff();

    backoff.recordFailure("telemetry:infra-ram", 1_000);
    expect(backoff.canAttempt("telemetry:infra-ram", 15_999)).toBe(false);
    expect(backoff.canAttempt("telemetry:infra-ram", 16_000)).toBe(true);

    backoff.recordFailure("telemetry:infra-ram", 16_000);
    expect(backoff.canAttempt("telemetry:infra-ram", 45_999)).toBe(false);
    expect(backoff.canAttempt("telemetry:infra-ram", 46_000)).toBe(true);

    backoff.recordFailure("telemetry:infra-ram", 46_000);
    backoff.recordFailure("telemetry:infra-ram", 106_000);
    expect(backoff.canAttempt("telemetry:infra-ram", 225_999)).toBe(false);
    expect(backoff.canAttempt("telemetry:infra-ram", 226_000)).toBe(true);

    backoff.recordSuccess("telemetry:infra-ram");
    expect(backoff.canAttempt("telemetry:infra-ram", 107_000)).toBe(true);
  });

  test("stores only ready tile snapshots and restores them for matching revisions", () => {
    const storage = createMemoryStorage();
    let now = 1_000;
    const store = createDashboardTileSnapshotStore(storage, {
      now: () => now,
    });

    store.saveReadyTile({
      currentRevisionId: "revision-a",
      response: {
        state: "ready",
        tile: { kind: "telemetry", id: "infra-ram" },
        item: {
          id: "infra-ram",
          label: "Infra RAM",
          value: { kind: "percent", value: 42 },
          detail: "live",
          severity: "ok",
        },
      },
      schemaVersion: "dashboard.v1",
    });
    store.saveTile({
      currentRevisionId: "revision-a",
      response: {
        state: "not_found",
        tile: { kind: "telemetry", id: "missing" },
        message: "Missing",
      },
      schemaVersion: "dashboard.v1",
    });
    store.saveTile({
      currentRevisionId: "revision-a",
      response: {
        state: "disabled",
        tile: { kind: "module", id: "disabled-module" },
        message: "Disabled",
      },
      schemaVersion: "dashboard.v1",
    });

    now = 61_000;

    const restored = store.restore({
      currentRevisionId: "revision-a",
      schemaVersion: "dashboard.v1",
    });

    expect(restored).toEqual([
      {
        ageMs: 60_000,
        response: {
          state: "ready",
          tile: { kind: "telemetry", id: "infra-ram" },
          item: {
            id: "infra-ram",
            label: "Infra RAM",
            value: { kind: "percent", value: 42 },
            detail: "live - stale 60s",
            severity: "stale",
          },
        },
      },
    ]);
    expect(store.restore({
      currentRevisionId: "revision-b",
      schemaVersion: "dashboard.v1",
    })).toEqual([]);
  });

  test("ignores malformed stored tile snapshots", () => {
    const storage = createMemoryStorage();
    storage.setItem(
      "dimensionlab.dashboard.tiles.v1",
      JSON.stringify({
        currentRevisionId: "revision-a",
        schemaVersion: "dashboard.v1",
        tiles: [
          {},
          {
            item: {
              id: "incomplete",
              detail: "live",
            },
            savedAt: 1_000,
            tile: { kind: "telemetry", id: "incomplete" },
          },
          {
            item: {
              id: "infra-ram",
              label: "Infra RAM",
              value: { kind: "percent", value: 42 },
              detail: "live",
              severity: "ok",
            },
            savedAt: 1_000,
            tile: { kind: "telemetry", id: "infra-ram" },
          },
          {
            item: { id: "broken-detail", detail: 42 },
            savedAt: 1_000,
            tile: { kind: "telemetry", id: "broken-detail" },
          },
        ],
        version: 1,
      }),
    );

    const store = createDashboardTileSnapshotStore(storage, {
      now: () => 16_000,
    });

    expect(store.restore({
      currentRevisionId: "revision-a",
      schemaVersion: "dashboard.v1",
    })).toEqual([
      {
        ageMs: 15_000,
        response: {
          state: "ready",
          tile: { kind: "telemetry", id: "infra-ram" },
          item: {
            id: "infra-ram",
            label: "Infra RAM",
            value: { kind: "percent", value: 42 },
            detail: "live - stale 15s",
            severity: "stale",
          },
        },
      },
    ]);
  });
});

async function waitFor(predicate: () => boolean) {
  for (let attempt = 0; attempt < 20; attempt += 1) {
    if (predicate()) return;
    await Promise.resolve();
  }

  throw new Error("condition was not met");
}

function createMemoryStorage(): Storage {
  const values = new Map<string, string>();
  return {
    get length() {
      return values.size;
    },
    clear() {
      values.clear();
    },
    getItem(key) {
      return values.get(key) ?? null;
    },
    key(index) {
      return [...values.keys()][index] ?? null;
    },
    removeItem(key) {
      values.delete(key);
    },
    setItem(key, value) {
      values.set(key, value);
    },
  };
}

function modelElement(modelId: string) {
  return {
    getAttribute(name: string) {
      return name === "data-model-id" ? modelId : null;
    },
  };
}
