import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { readFileSync, readdirSync } from "node:fs";
import { resolve } from "node:path";
import {
  ZERO_INSETS,
  parseNativeInsets,
  safeAreaCssVars,
  applySafeAreaInsets,
  readNativeInsets,
  initSafeArea,
  INSETS_CHANGED_EVENT,
} from "./safeArea";

/**
 * Safe-area (window inset) plumbing for Android.
 *
 * TRACES: UR-066 | IR-031, DR-112 | UT-094, UT-095, UT-096, UT-097
 *
 * Regression guard for "the bottom nav is off the bottom of the screen on some
 * devices (Motorola) but not others (Fairphone)".
 *
 * Two independent defects produced it:
 *
 *  1. `src/app.html` shipped `<meta name="viewport" content="width=device-width,
 *     initial-scale=1">` — no `viewport-fit=cover`. Per the CSS Env spec, every
 *     `env(safe-area-inset-*)` resolves to **0px** unless the viewport opts into
 *     `cover`. So the `env()` padding in app.css and BottomUi.svelte was a
 *     no-op on every device.
 *  2. Even with `viewport-fit=cover`, Android WebView only maps the **display
 *     cutout** into `env(safe-area-inset-*)` — never the status bar or the
 *     navigation/gesture bar. MainActivity calls `enableEdgeToEdge()` and the
 *     app targets SDK 36 (edge-to-edge is mandatory from SDK 35 and the opt-out
 *     is ignored from SDK 36), so the WebView always spans the full window
 *     including the system bars. CSS alone can never learn about them.
 *
 * The device split was only in how much the bars intrude: a thin translucent
 * gesture pill overlaps harmlessly, a tall opaque 3-button bar swallows the nav
 * outright. Both devices were equally unpadded.
 *
 * The fix pushes real `WindowInsets` from Kotlin into CSS custom properties.
 * These tests pin the frontend half of that contract.
 */
describe("parseNativeInsets", () => {
  it("parses the JSON payload the native bridge returns", () => {
    expect(parseNativeInsets('{"top":24,"right":0,"bottom":48,"left":0}')).toEqual({
      top: 24,
      right: 0,
      bottom: 48,
      left: 0,
    });
  });

  it("accepts an already-parsed object", () => {
    expect(parseNativeInsets({ top: 1, right: 2, bottom: 3, left: 4 })).toEqual({
      top: 1,
      right: 2,
      bottom: 3,
      left: 4,
    });
  });

  it("treats missing, non-finite and negative edges as zero rather than emitting NaN", () => {
    // A NaN would serialise to "NaNpx" and silently kill the whole padding
    // declaration, which is exactly the failure mode being guarded against.
    expect(parseNativeInsets('{"top":-5,"bottom":"48"}')).toEqual({
      top: 0,
      right: 0,
      bottom: 48,
      left: 0,
    });
    expect(parseNativeInsets({ top: Number.NaN, right: Infinity })).toEqual(ZERO_INSETS);
  });

  it("returns null for input that is not an inset payload at all", () => {
    expect(parseNativeInsets("not json")).toBeNull();
    expect(parseNativeInsets(null)).toBeNull();
    expect(parseNativeInsets(undefined)).toBeNull();
    expect(parseNativeInsets(42)).toBeNull();
  });
});

describe("safeAreaCssVars / applySafeAreaInsets", () => {
  it("emits px-suffixed custom properties for all four edges", () => {
    expect(safeAreaCssVars({ top: 24, right: 0, bottom: 48, left: 12 })).toEqual({
      "--jt-inset-top": "24px",
      "--jt-inset-right": "0px",
      "--jt-inset-bottom": "48px",
      "--jt-inset-left": "12px",
    });
  });

  it("writes the custom properties onto the target element", () => {
    const el = document.createElement("div");
    applySafeAreaInsets(el, { top: 24, right: 1, bottom: 48, left: 2 });

    expect(el.style.getPropertyValue("--jt-inset-top")).toBe("24px");
    expect(el.style.getPropertyValue("--jt-inset-right")).toBe("1px");
    expect(el.style.getPropertyValue("--jt-inset-bottom")).toBe("48px");
    expect(el.style.getPropertyValue("--jt-inset-left")).toBe("2px");
  });
});

describe("readNativeInsets", () => {
  beforeEach(() => {
    delete (window as unknown as Record<string, unknown>).AndroidInsets;
    vi.restoreAllMocks();
  });

  it("returns null when the bridge is absent (desktop, iOS, dev server)", () => {
    expect(readNativeInsets()).toBeNull();
  });

  it("reads and parses the bridge payload", () => {
    window.AndroidInsets = { get: () => '{"top":24,"right":0,"bottom":48,"left":0}' };
    expect(readNativeInsets()).toEqual({ top: 24, right: 0, bottom: 48, left: 0 });
  });

  it("returns null when the bridge object is a stale WebView proxy", () => {
    // Same failure mode as the background-audio bridge: the injected object
    // stays truthy across a page load while its methods vanish. Must not throw
    // out of layout init.
    vi.spyOn(console, "warn").mockImplementation(() => {});
    window.AndroidInsets = {} as unknown as { get(): string };
    expect(readNativeInsets()).toBeNull();

    window.AndroidInsets = {
      get: () => {
        throw new TypeError("get is not a function");
      },
    };
    expect(readNativeInsets()).toBeNull();
  });
});

describe("initSafeArea", () => {
  let stop: (() => void) | null = null;

  beforeEach(() => {
    delete (window as unknown as Record<string, unknown>).AndroidInsets;
    document.documentElement.removeAttribute("style");
    vi.restoreAllMocks();
  });

  afterEach(() => {
    stop?.();
    stop = null;
  });

  it("primes the document element from the bridge on start", () => {
    window.AndroidInsets = { get: () => '{"top":24,"right":0,"bottom":48,"left":0}' };

    stop = initSafeArea();

    expect(document.documentElement.style.getPropertyValue("--jt-inset-bottom")).toBe("48px");
  });

  it("re-applies insets when native reports a change (rotation, nav-mode switch)", () => {
    let payload = '{"top":24,"right":0,"bottom":48,"left":0}';
    window.AndroidInsets = { get: () => payload };

    stop = initSafeArea();
    expect(document.documentElement.style.getPropertyValue("--jt-inset-top")).toBe("24px");

    // Rotated to landscape: the cutout moves to the left edge, the gesture bar
    // shrinks. Native re-pushes and fires the change event.
    payload = '{"top":0,"right":0,"bottom":24,"left":44}';
    window.dispatchEvent(new CustomEvent(INSETS_CHANGED_EVENT));

    expect(document.documentElement.style.getPropertyValue("--jt-inset-top")).toBe("0px");
    expect(document.documentElement.style.getPropertyValue("--jt-inset-left")).toBe("44px");
    expect(document.documentElement.style.getPropertyValue("--jt-inset-bottom")).toBe("24px");
  });

  it("leaves the custom properties unset with no bridge, so env() keeps the field", () => {
    // On iOS/desktop the `env(safe-area-inset-*)` half of the max() must win;
    // writing an explicit 0px here would clobber it.
    stop = initSafeArea();
    expect(document.documentElement.style.getPropertyValue("--jt-inset-bottom")).toBe("");
  });

  it("stops listening once torn down", () => {
    let payload = '{"top":24,"right":0,"bottom":48,"left":0}';
    window.AndroidInsets = { get: () => payload };

    const teardown = initSafeArea();
    teardown();

    payload = '{"top":99,"right":99,"bottom":99,"left":99}';
    window.dispatchEvent(new CustomEvent(INSETS_CHANGED_EVENT));

    expect(document.documentElement.style.getPropertyValue("--jt-inset-top")).toBe("24px");
  });
});

/**
 * Static guards. The two root-cause defects were both single lines of markup /
 * CSS that no runtime test could reach, so pin them at the source level.
 */
describe("safe-area wiring in source", () => {
  const read = (rel: string) => readFileSync(resolve(process.cwd(), rel), "utf8");

  it("app.html opts the viewport into viewport-fit=cover", () => {
    const viewport = read("src/app.html").match(/<meta\s+name="viewport"[^>]*>/i)?.[0];

    expect(viewport, "no viewport meta tag found in src/app.html").toBeTruthy();
    expect(viewport).toMatch(/viewport-fit\s*=\s*cover/);
  });

  it("app.css derives --safe-* from both env() and the native --jt-inset-* vars", () => {
    const css = read("src/app.css");

    for (const edge of ["top", "right", "bottom", "left"]) {
      expect(css).toMatch(
        new RegExp(
          `--safe-${edge}:\\s*max\\(\\s*env\\(safe-area-inset-${edge}[^)]*\\)\\s*,\\s*var\\(--jt-inset-${edge}[^)]*\\)\\s*\\)`
        )
      );
    }
  });

  it("no component pads directly from env() — everything goes through --safe-*", () => {
    // A bare env() is 0 in Android WebView for the system bars, which is the
    // bug. app.css is the one legal place it may appear (inside the max()).
    const offenders = [
      "src/lib/components/BottomUi.svelte",
      "src/routes/+layout.svelte",
      "src/lib/components/player/VideoPlayer.svelte",
      "src/lib/components/player/AudioPlayer.svelte",
    ].filter((f) => read(f).includes("env(safe-area-inset"));

    expect(offenders).toEqual([]);
  });

  it("the bottom UI reserves the bottom inset so the nav clears the gesture bar", () => {
    expect(read("src/lib/components/BottomUi.svelte")).toMatch(/pb-\[var\(--safe-bottom\)\]/);
  });

  it("only the app shell measures itself against the viewport", () => {
    // The shell is `h-screen` AND inset-padded, so its content box is
    // `100vh - safe-top`. Any nested `h-screen`/`min-h-screen` is therefore
    // taller than the space it was given and overflows by exactly the inset —
    // the library column's `h-screen` clipped its own BottomUi that way. Nested
    // full-height boxes must use `h-full`/`min-h-full` and inherit the shell's
    // already-inset height.
    const svelteFilesIn = (dir: string): string[] =>
      readdirSync(resolve(process.cwd(), dir), { recursive: true, encoding: "utf8" })
        .filter((f) => f.endsWith(".svelte"))
        .map((f) => `${dir}/${f}`);

    const offenders = [...svelteFilesIn("src/routes"), ...svelteFilesIn("src/lib/components")]
      .filter((f) => f !== "src/routes/+layout.svelte")
      .filter((f) => /class=[^>]*\bh-screen\b|class=[^>]*\bmin-h-screen\b/.test(read(f)));

    expect(offenders).toEqual([]);
  });
});
