/**
 * Phase 7.2 R1 Agent B — HDR (Hot Data Revalidation) server-side tests.
 *
 * Coverage:
 *   1. Slot file detection — correctly identifies `.slot.ts` and
 *      `.slot.tsx` files, rejects other extensions.
 *   2. routeId mapping — given a manifest with `slotModule`, the
 *      normalized path comparison finds the owning route and
 *      correctly returns `null` for unrelated paths.
 *   3. rebuildId monotonicity — `broadcastVite` assigns envelope ids
 *      that strictly increase, so the replay buffer is compatible
 *      with HDR custom events.
 *   4. timestamp freshness — emitted HDR payload timestamps are
 *      within 1000 ms of Date.now().
 *   5. HMR server broadcasts custom slot-refetch over the wire — the
 *      payload reaches connected clients with the expected shape.
 *   6. HDR opt-out env — when `MANDU_HDR=0` the helper indicates HDR
 *      disabled so the caller falls back to legacy reload path.
 *
 * These tests exercise the PURE contract: path normalization, lookup
 * semantics, and the HMR server's broadcast shape. The browser-side
 * fetch + startTransition path lives in `hdr-client.test.ts` (pure
 * unit) and `fast-refresh.spec.ts` (Playwright e2e).
 *
 * References:
 *   docs/bun/phase-7-2-team-plan.md §3 Agent B
 *   packages/core/src/bundler/hmr-types.ts — HDRPayload
 *   packages/cli/src/commands/dev.ts — findRouteIdForSlot / isSlotFile
 */

import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import path from "path";
import { tmpdir } from "os";
import {
  createHMRServer,
  _testOnly_normalizeFsPath,
  type HMRServer,
} from "../dev";
import type { RoutesManifest } from "../../spec/schema";
import type { HDRPayload, ViteHMRPayload } from "../hmr-types";
import { PORTS } from "../../constants";

// -----------------------------------------------------------------------------
// Mirrored helpers — we reproduce the CLI helper logic here so the test
// file doesn't have to import from `@mandujs/cli` (circular package
// dependency). Any divergence across the two implementations is a test
// failure we want, not a silent skew.
// -----------------------------------------------------------------------------

function isSlotFile(filePath: string): boolean {
  return filePath.endsWith(".slot.ts") || filePath.endsWith(".slot.tsx");
}

function normalizeCompareFactory(rootDir: string) {
  return (p: string): string => {
    const resolved = path.resolve(rootDir, p).replace(/\\/g, "/");
    return process.platform === "win32" ? resolved.toLowerCase() : resolved;
  };
}

function findRouteIdForSlot(
  manifest: RoutesManifest,
  rootDir: string,
  filePath: string,
): string | null {
  const normalizeCompare = normalizeCompareFactory(rootDir);
  const target = normalizeCompare(filePath);
  for (const route of manifest.routes) {
    if (!route.slotModule) continue;
    if (normalizeCompare(route.slotModule) === target) {
      return route.id;
    }
  }
  return null;
}

// -----------------------------------------------------------------------------
// Fixtures
// -----------------------------------------------------------------------------

function buildManifest(): RoutesManifest {
  return {
    version: 1,
    routes: [
      {
        id: "home",
        kind: "page",
        pattern: "/",
        module: "app/page.tsx",
        componentModule: "app/page.tsx",
        slotModule: "app/page.slot.ts",
      },
      {
        id: "dashboard",
        kind: "page",
        pattern: "/dashboard",
        module: "app/dashboard/page.tsx",
        componentModule: "app/dashboard/page.tsx",
        slotModule: "app/dashboard/page.slot.ts",
      },
      {
        id: "about",
        kind: "page",
        pattern: "/about",
        module: "app/about/page.tsx",
        componentModule: "app/about/page.tsx",
        // no slotModule — proves the falsy-skip branch
      },
    ],
  } as RoutesManifest;
}

// -----------------------------------------------------------------------------
// Section A — Slot detection + routeId mapping
// -----------------------------------------------------------------------------

describe("Phase 7.2 Agent B — slot detection", () => {
  test("[1] isSlotFile accepts .slot.ts and .slot.tsx", () => {
    expect(isSlotFile("app/page.slot.ts")).toBe(true);
    expect(isSlotFile("app/page.slot.tsx")).toBe(true);
    // Full absolute paths also work (the helper only checks the suffix).
    expect(
      isSlotFile(path.resolve(tmpdir(), "proj/app/dashboard/page.slot.ts")),
    ).toBe(true);
  });

  test("[1b] isSlotFile rejects non-slot files", () => {
    expect(isSlotFile("app/page.tsx")).toBe(false);
    expect(isSlotFile("app/page.ts")).toBe(false);
    expect(isSlotFile("app/page.client.tsx")).toBe(false);
    expect(isSlotFile("app/layout.tsx")).toBe(false);
    // Files with ".slot" in the NAME but not as suffix must not match.
    expect(isSlotFile("app/slot-like.ts")).toBe(false);
    expect(isSlotFile("app/not-a.slot.js")).toBe(false);
  });
});

describe("Phase 7.2 Agent B — findRouteIdForSlot", () => {
  test("[2] returns the owning routeId when the slot path matches exactly", () => {
    const rootDir = path.join(tmpdir(), "mandu-hdr-fixture-1");
    const manifest = buildManifest();
    const absSlotPath = path.resolve(rootDir, "app/page.slot.ts");
    expect(findRouteIdForSlot(manifest, rootDir, absSlotPath)).toBe("home");

    const absDashboard = path.resolve(rootDir, "app/dashboard/page.slot.ts");
    expect(findRouteIdForSlot(manifest, rootDir, absDashboard)).toBe("dashboard");
  });

  test("[2b] normalizes Windows backslashes + forward-slashes identically", () => {
    const rootDir = path.join(tmpdir(), "mandu-hdr-fixture-2");
    const manifest = buildManifest();
    // Build the path with backslashes regardless of platform — the
    // helper must normalize it before comparing.
    const backslashPath = path
      .resolve(rootDir, "app/page.slot.ts")
      .replace(/\//g, "\\");
    const forwardPath = path
      .resolve(rootDir, "app/page.slot.ts")
      .replace(/\\/g, "/");
    expect(findRouteIdForSlot(manifest, rootDir, backslashPath)).toBe("home");
    expect(findRouteIdForSlot(manifest, rootDir, forwardPath)).toBe("home");
  });

  test("[2c] returns null when slot path doesn't match any route", () => {
    const rootDir = path.join(tmpdir(), "mandu-hdr-fixture-3");
    const manifest = buildManifest();
    const unrelated = path.resolve(rootDir, "app/orphan.slot.ts");
    expect(findRouteIdForSlot(manifest, rootDir, unrelated)).toBe(null);
  });

  test("[2d] skips routes without slotModule — doesn't throw on falsy", () => {
    const rootDir = path.join(tmpdir(), "mandu-hdr-fixture-4");
    // Manifest with only one route, no slotModule.
    const manifest: RoutesManifest = {
      version: 1,
      routes: [
        {
          id: "home",
          kind: "page",
          pattern: "/",
          module: "app/page.tsx",
          componentModule: "app/page.tsx",
        },
      ],
    } as RoutesManifest;
    const anySlot = path.resolve(rootDir, "app/page.slot.ts");
    expect(findRouteIdForSlot(manifest, rootDir, anySlot)).toBe(null);
  });

  test("[4] after handler re-registration, subsequent slot change still resolves", () => {
    // Simulates the dev.ts flow: the same manifest instance survives
    // across multiple slot edits. The helper must be stable — no
    // internal mutation.
    const rootDir = path.join(tmpdir(), "mandu-hdr-fixture-5");
    const manifest = buildManifest();
    const slotPath = path.resolve(rootDir, "app/page.slot.ts");
    for (let i = 0; i < 3; i++) {
      expect(findRouteIdForSlot(manifest, rootDir, slotPath)).toBe("home");
    }
  });
});

// -----------------------------------------------------------------------------
// Section B — HMR server broadcast of slot-refetch
// -----------------------------------------------------------------------------

const HMR_TEST_PORT_STATE = "__MANDU_HMR_TEST_PORT_STATE__";

function pickPort(): number {
  const stateGlobal = globalThis as typeof globalThis & {
    __MANDU_HMR_TEST_PORT_STATE__?: { next: number };
  };
  stateGlobal.__MANDU_HMR_TEST_PORT_STATE__ ??= { next: 0 };
  const index = stateGlobal.__MANDU_HMR_TEST_PORT_STATE__.next++;
  return 41000 + (((process.pid % 3500) * 2 + index * 2) % 7000);
}

describe("Phase 7.2 Agent B — slot-refetch broadcast", () => {
  let server: HMRServer | null = null;
  let hmrPort = 0;

  beforeEach(() => {
    const basePort = pickPort();
    hmrPort = basePort + PORTS.HMR_OFFSET;
    server = createHMRServer(basePort);
  });

  afterEach(() => {
    server?.close();
    server = null;
  });

  test("[3] broadcastVite custom slot-refetch receives monotonic envelope id", () => {
    const e1 = server!.broadcastVite({
      type: "custom",
      event: "mandu:slot-refetch",
      data: { routeId: "home", slotPath: "app/page.slot.ts", timestamp: Date.now() },
    });
    const e2 = server!.broadcastVite({
      type: "custom",
      event: "mandu:slot-refetch",
      data: { routeId: "home", slotPath: "app/page.slot.ts", timestamp: Date.now() },
    });
    expect(e2.id).toBeGreaterThan(e1.id);
  });

  test("[5] custom slot-refetch reaches the client with expected shape", async () => {
    const ws = new WebSocket(`ws://localhost:${hmrPort}/`);
    await new Promise<void>((resolve, reject) => {
      ws.addEventListener("open", () => resolve(), { once: true });
      ws.addEventListener("error", (e) => reject(e as unknown as Error), {
        once: true,
      });
    });

    const messages: Array<{
      type: string;
      payload?: { type: string; event?: string; data?: unknown };
    }> = [];
    ws.addEventListener("message", (ev) => {
      try {
        messages.push(JSON.parse(String((ev as MessageEvent).data)));
      } catch {
        // ignore
      }
    });

    // Drain the 'connected' greeting if any.
    await new Promise((r) => setTimeout(r, 50));

    const payload: ViteHMRPayload = {
      type: "custom",
      event: "mandu:slot-refetch",
      data: {
        routeId: "dashboard",
        slotPath: "app/dashboard/page.slot.ts",
        timestamp: Date.now(),
      },
    };
    server!.broadcastVite(payload);

    // Poll for the message to land.
    const deadline = Date.now() + 2000;
    while (Date.now() < deadline) {
      if (messages.some((m) => m.type === "vite" && m.payload?.type === "custom")) {
        break;
      }
      await new Promise((r) => setTimeout(r, 20));
    }
    const custom = messages.find(
      (m) => m.type === "vite" && m.payload?.type === "custom",
    );
    expect(custom).toBeDefined();
    expect(custom?.payload?.event).toBe("mandu:slot-refetch");
    expect(((custom?.payload?.data ?? {}) as { routeId: string }).routeId).toBe(
      "dashboard",
    );
    ws.close();
  });

  test("[6] HDR disabled via env (MANDU_HDR=0) — helper check", () => {
    // The helper itself is a one-liner but we lock the semantics as a
    // regression guard. The CLI uses `process.env.MANDU_HDR !== "0"`
    // so any non-"0" value (including unset) enables HDR.
    const prev = process.env.MANDU_HDR;
    try {
      process.env.MANDU_HDR = "0";
      expect(process.env.MANDU_HDR !== "0").toBe(false);
      process.env.MANDU_HDR = "1";
      expect(process.env.MANDU_HDR !== "0").toBe(true);
      delete process.env.MANDU_HDR;
      expect(process.env.MANDU_HDR !== "0").toBe(true);
    } finally {
      if (prev === undefined) {
        delete process.env.MANDU_HDR;
      } else {
        process.env.MANDU_HDR = prev;
      }
    }
  });
});

// -----------------------------------------------------------------------------
// Section C — HDRPayload shape contract
// -----------------------------------------------------------------------------

describe("Phase 7.2 Agent B — HDRPayload shape", () => {
  test("[7] HDRPayload type includes routeId / slotPath / rebuildId / timestamp", () => {
    // Compile-time assertion: if the HDRPayload type shape changes in
    // hmr-types.ts without updating this test, TypeScript compilation
    // fails. Run-time assertion just locks the value shape.
    const payload: HDRPayload = {
      type: "slot-refetch",
      routeId: "home",
      slotPath: "app/page.slot.ts",
      rebuildId: 1,
      timestamp: Date.now(),
    };
    expect(payload.type).toBe("slot-refetch");
    expect(payload.routeId).toBe("home");
    expect(payload.slotPath).toBe("app/page.slot.ts");
    expect(typeof payload.rebuildId).toBe("number");
    expect(typeof payload.timestamp).toBe("number");
    expect(Math.abs(payload.timestamp - Date.now())).toBeLessThan(1000);
  });

  test("[8] path normalization round-trip through _testOnly_normalizeFsPath", () => {
    // The `findRouteIdForSlot` helper must use the same normalization
    // the bundler's `startDevBundler` manifest iteration uses so both
    // sides of the comparison produce identical keys.
    const abs = path.resolve(tmpdir(), "proj/app/page.slot.ts");
    const n1 = _testOnly_normalizeFsPath(abs);
    expect(n1.endsWith("/app/page.slot.ts")).toBe(true);
    expect(n1.includes("\\")).toBe(false);
    // `.slot.tsx` variant normalizes identically (permitted by guard).
    const abs2 = path.resolve(tmpdir(), "proj/app/page.slot.tsx");
    const n2 = _testOnly_normalizeFsPath(abs2);
    expect(n2.endsWith("/app/page.slot.tsx")).toBe(true);
  });
});
