import { test, expect } from "bun:test";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { existsSync } from "node:fs";
import {
  createContext,
  rateLimit,
  requestLogger,
  TTLCache,
  cacheControl,
  withCacheControl,
  etag,
  notModified,
  saveUpload,
  collectUploads,
  sanitizeFilename,
  UploadError,
  setSessionBackend,
  loadSession,
  type SessionBackend,
  type SessionEntry,
  type AsyncSessionBackend,
  type RateLimitStore,
} from "../src/index.ts";

function memoryBackend(): SessionBackend {
  const map = new Map<string, SessionEntry>();
  return {
    get: (id) => map.get(id),
    set: (id, e) => void map.set(id, e),
    delete: (id) => void map.delete(id),
    _map: map,
  } as SessionBackend & { _map: Map<string, SessionEntry> };
}

function ctx(path = "/", headers: Record<string, string> = {}) {
  const url = new URL(`http://x${path}`);
  return createContext(new Request(url, { headers }), url);
}

// --- rate limiting ---------------------------------------------------------

test("rateLimit (trustProxy) allows up to max then 429 with headers", async () => {
  const mw = rateLimit({ max: 2, windowMs: 60_000, trustProxy: true });
  const ok = () => new Response("ok");
  const key = { "x-forwarded-for": "1.1.1.1" };

  const r1 = await mw(ctx("/", key), ok);
  const r2 = await mw(ctx("/", key), ok);
  const r3 = await mw(ctx("/", key), ok);
  expect(r1.status).toBe(200);
  expect(r1.headers.get("RateLimit-Remaining")).toBe("1");
  expect(r2.status).toBe(200);
  expect(r2.headers.get("RateLimit-Remaining")).toBe("0");
  expect(r3.status).toBe(429);
  expect(r3.headers.get("retry-after")).toBeTruthy();
});

test("rateLimit (trustProxy) buckets are independent per key", async () => {
  const mw = rateLimit({ max: 1, windowMs: 60_000, trustProxy: true });
  const ok = () => new Response("ok");
  const a = await mw(ctx("/", { "x-forwarded-for": "2.2.2.2" }), ok);
  const b = await mw(ctx("/", { "x-forwarded-for": "3.3.3.3" }), ok);
  expect(a.status).toBe(200);
  expect(b.status).toBe(200);
});

test("setSessionBackend routes session data through a custom backend", () => {
  const backend = memoryBackend() as SessionBackend & { _map: Map<string, SessionEntry> };
  setSessionBackend(backend);
  try {
    const c = ctx("/");
    c.session.set("k", "v");
    const id = c.session.id();
    expect(backend._map.has(id)).toBe(true);
    expect(backend._map.get(id)!.data).toEqual({ k: "v" });
  } finally {
    setSessionBackend(memoryBackend()); // restore an equivalent for other tests
  }
});

test("rateLimit accepts a custom (shared) store", async () => {
  const hits: string[] = [];
  const store: RateLimitStore = {
    hit(key, windowMs, now) {
      hits.push(key);
      return { count: hits.filter((k) => k === key).length, resetAt: now + windowMs };
    },
  };
  const mw = rateLimit({ max: 1, windowMs: 1000, store, trustProxy: true });
  const ok = () => new Response("ok");
  const key = { "x-forwarded-for": "5.5.5.5" };
  expect((await mw(ctx("/", key), ok)).status).toBe(200);
  expect((await mw(ctx("/", key), ok)).status).toBe(429);
  expect(hits.length).toBe(2); // both requests went through the injected store
});

test("rateLimit awaits an ASYNC store (e.g. Redis)", async () => {
  const counts = new Map<string, number>();
  const store: RateLimitStore = {
    async hit(key, windowMs, now) {
      const n = (counts.get(key) ?? 0) + 1;
      counts.set(key, n);
      return { count: n, resetAt: now + windowMs };
    },
  };
  const mw = rateLimit({ max: 1, windowMs: 1000, store, trustProxy: true });
  const ok = () => new Response("ok");
  const key = { "x-forwarded-for": "7.7.7.7" };
  expect((await mw(ctx("/", key), ok)).status).toBe(200);
  expect((await mw(ctx("/", key), ok)).status).toBe(429);
});

test("loadSession persists a session through an ASYNC backend across requests", async () => {
  const kv = new Map<string, SessionEntry>();
  const backend: AsyncSessionBackend = {
    load: async (id) => kv.get(id),
    save: async (id, entry) => void kv.set(id, entry),
    destroy: async (id) => void kv.delete(id),
  };
  const mw = loadSession(backend);

  // Request 1: write a value, capture the issued session id.
  const c1 = ctx("/");
  await mw(c1, () => {
    c1.session.set("hits", 1);
    return new Response("ok");
  });
  const sid = c1.cookies.get("wrnexus.sid")!;
  expect(sid).toBeTruthy();
  expect(kv.has(sid)).toBe(true); // saved to the async backend

  // Request 2: same cookie → session loads from the backend.
  const c2 = ctx("/", { cookie: `wrnexus.sid=${sid}` });
  let seen: unknown;
  await mw(c2, () => {
    seen = c2.session.get("hits");
    return new Response("ok");
  });
  expect(seen).toBe(1);
});

test("rateLimit default keys on the non-spoofable peer IP, not XFF headers", async () => {
  const mw = rateLimit({ max: 1, windowMs: 60_000 });
  const ok = () => new Response("ok");
  // Same peer IP, different spoofed XFF → still one bucket (XFF ignored).
  const c1 = ctx("/", { "x-forwarded-for": "9.9.9.9" });
  c1.ip = "10.0.0.1";
  const c2 = ctx("/", { "x-forwarded-for": "8.8.8.8" });
  c2.ip = "10.0.0.1";
  expect((await mw(c1, ok)).status).toBe(200);
  expect((await mw(c2, ok)).status).toBe(429);
});

test("rateLimit validates its in-memory key bound", () => {
  expect(() => rateLimit({ maxKeys: 0 })).toThrow("maxKeys");
});

// --- request logging -------------------------------------------------------

test("requestLogger emits a structured record with duration and id", async () => {
  const records: string[] = [];
  let t = 1000;
  const mw = requestLogger({
    format: "json",
    sink: (line) => records.push(line),
    now: () => (t += 5),
  });
  const res = await mw(ctx("/api/users"), () => new Response("x", { status: 201 }));
  expect(res.status).toBe(201);
  const rec = JSON.parse(records[0]!);
  expect(rec.method).toBe("GET");
  expect(rec.path).toBe("/api/users");
  expect(rec.status).toBe(201);
  expect(rec.durationMs).toBeGreaterThanOrEqual(0);
  expect(rec.id).toBeTruthy();
});

test("requestLogger logs status 500 when the handler throws", async () => {
  const records: string[] = [];
  const mw = requestLogger({ format: "json", sink: (l) => records.push(l) });
  await expect(
    mw(ctx("/boom"), () => {
      throw new Error("nope");
    }),
  ).rejects.toThrow("nope");
  expect(JSON.parse(records[0]!).status).toBe(500);
});

// --- caching ---------------------------------------------------------------

test("TTLCache getOrLoad caches until expiry", async () => {
  const cache = new TTLCache<number>(60_000);
  let calls = 0;
  const load = () => {
    calls++;
    return 42;
  };
  expect(await cache.getOrLoad("k", load)).toBe(42);
  expect(await cache.getOrLoad("k", load)).toBe(42);
  expect(calls).toBe(1);
  cache.delete("k");
  expect(await cache.getOrLoad("k", load)).toBe(42);
  expect(calls).toBe(2);
});

test("TTLCache coalesces concurrent loads for the same key", async () => {
  const cache = new TTLCache<number>();
  let calls = 0;
  const loader = async () => {
    calls++;
    await Promise.resolve();
    return 7;
  };
  expect(await Promise.all([cache.getOrLoad("x", loader), cache.getOrLoad("x", loader)])).toEqual([
    7, 7,
  ]);
  expect(calls).toBe(1);
});

test("TTLCache does not let an old in-flight load overwrite set, delete, or clear", async () => {
  const cache = new TTLCache<number>();
  let release!: (value: number) => void;
  const loading = cache.getOrLoad("x", () => new Promise<number>((resolve) => (release = resolve)));
  await Promise.resolve();
  cache.set("x", 9);
  release(1);
  expect(await loading).toBe(1);
  expect(cache.get("x")).toBe(9);

  let releaseClear!: (value: number) => void;
  const clearing = cache.getOrLoad(
    "y",
    () => new Promise<number>((resolve) => (releaseClear = resolve)),
  );
  await Promise.resolve();
  cache.clear();
  releaseClear(2);
  await clearing;
  expect(cache.get("y")).toBeUndefined();
});

test("cacheControl builds directives; no-store wins", () => {
  expect(cacheControl({ maxAge: 60, sMaxAge: 120 })).toBe("public, max-age=60, s-maxage=120");
  expect(cacheControl({ private: true, noCache: true })).toBe("private, no-cache");
  expect(cacheControl({ noStore: true, maxAge: 99 })).toBe("no-store");
  const res = withCacheControl(new Response("x"), { maxAge: 30, immutable: true });
  expect(res.headers.get("Cache-Control")).toBe("public, max-age=30, immutable");
});

test("etag + notModified drive conditional requests", () => {
  const tag = etag("hello world");
  expect(tag).toMatch(/^W\/"/);
  expect(etag("hello world")).toBe(tag); // stable
  expect(etag("different")).not.toBe(tag);
  const req = new Request("http://x", { headers: { "if-none-match": tag } });
  expect(notModified(req, tag)).toBe(true);
  expect(notModified(new Request("http://x"), tag)).toBe(false);
});

// --- uploads ---------------------------------------------------------------

test("sanitizeFilename strips traversal and separators", () => {
  const s = sanitizeFilename("../../etc/passwd");
  expect(s).not.toContain("/");
  expect(s).not.toContain("..");
  expect(s).toContain("passwd");
  expect(sanitizeFilename("a/b\\c.png")).toBe("a_b_c.png");
  expect(sanitizeFilename("")).toBe("upload");
});

test("saveUpload writes a validated file and enforces limits", async () => {
  const dir = join(tmpdir(), "wire-upload-test");
  const file = new File(["hello upload"], "note.txt", { type: "text/plain" });

  const saved = await saveUpload(file, { dir, allowedTypes: ["text/plain", ".txt"] });
  expect(saved.filename).toBe("note.txt");
  expect(saved.size).toBe(12);
  expect(existsSync(saved.path)).toBe(true);

  await expect(saveUpload(file, { dir, maxBytes: 4 })).rejects.toThrow(UploadError);
  await expect(saveUpload(file, { dir, allowedTypes: ["image/png"] })).rejects.toThrow(UploadError);
});

test("collectUploads returns only non-empty File fields", async () => {
  const form = new FormData();
  form.append("name", "ada");
  form.append("avatar", new File(["img"], "a.png", { type: "image/png" }));
  const uploads = collectUploads(form);
  expect(uploads.length).toBe(1);
  expect(uploads[0]!.field).toBe("avatar");
});
