import { test } from "node:test";
import assert from "node:assert/strict";
import {
  HttpResult,
  matchPath,
  httpResultToResponse,
  headResponse,
  applyCors,
  corsPreflightResponse,
  applySecurityHeaders,
  weakETag,
  applyCache,
  notModifiedIfMatch,
  type CorsPolicy,
  type SecurityPolicy,
} from "../src/http.ts";
import type { JsonValue } from "../src/boundary.ts";

const listPolicy: CorsPolicy = {
  origins: ["https://app.example.com", "https://admin.example.com"],
  allowMethods: ["GET", "POST", "OPTIONS"],
  allowHeaders: ["content-type", "authorization"],
  credentials: true,
  maxAgeSecs: 3600,
};

const wildcardPolicy: CorsPolicy = {
  origins: ["*"],
  allowMethods: ["GET", "OPTIONS"],
  allowHeaders: ["content-type"],
  credentials: false,
  maxAgeSecs: null,
};

test("matchPath: captures and decodes params", () => {
  assert.deepEqual(matchPath("/orders/:id", "/orders/abc%20123"), { params: { id: "abc 123" } });
  assert.deepEqual(matchPath("/a/:x/b/:y", "/a/1/b/2"), { params: { x: "1", y: "2" } });
});

test("matchPath: returns null on literal or length mismatch", () => {
  assert.equal(matchPath("/orders/:id", "/customers/1"), null);
  assert.equal(matchPath("/orders/:id", "/orders/1/extra"), null);
});

const id = (v: number): JsonValue => v;

test("httpResultToResponse: status codes map per variant", async () => {
  // 2xx success.
  assert.equal(httpResultToResponse(HttpResult.Ok(1), id).status, 200);
  assert.equal(httpResultToResponse(HttpResult.Created(1), id).status, 201);
  assert.equal(httpResultToResponse(HttpResult.Accepted(1), id).status, 202);
  assert.equal(httpResultToResponse(HttpResult.NoContent, id).status, 204);
  // 3xx redirection.
  assert.equal(httpResultToResponse(HttpResult.MovedPermanently("/x"), id).status, 301);
  assert.equal(httpResultToResponse(HttpResult.Found("/x"), id).status, 302);
  assert.equal(httpResultToResponse(HttpResult.SeeOther("/x"), id).status, 303);
  assert.equal(httpResultToResponse(HttpResult.TemporaryRedirect("/x"), id).status, 307);
  assert.equal(httpResultToResponse(HttpResult.PermanentRedirect("/x"), id).status, 308);
  // 4xx client error.
  assert.equal(httpResultToResponse(HttpResult.BadRequest("b"), id).status, 400);
  assert.equal(httpResultToResponse(HttpResult.Unauthorized, id).status, 401);
  assert.equal(httpResultToResponse(HttpResult.Forbidden, id).status, 403);
  assert.equal(httpResultToResponse(HttpResult.NotFound, id).status, 404);
  assert.equal(httpResultToResponse(HttpResult.MethodNotAllowed, id).status, 405);
  assert.equal(httpResultToResponse(HttpResult.NotAcceptable, id).status, 406);
  assert.equal(httpResultToResponse(HttpResult.RequestTimeout, id).status, 408);
  assert.equal(httpResultToResponse(HttpResult.Conflict("c"), id).status, 409);
  assert.equal(httpResultToResponse(HttpResult.Gone, id).status, 410);
  assert.equal(httpResultToResponse(HttpResult.LengthRequired, id).status, 411);
  assert.equal(httpResultToResponse(HttpResult.PayloadTooLarge("p"), id).status, 413);
  assert.equal(httpResultToResponse(HttpResult.UnsupportedMediaType("m"), id).status, 415);
  assert.equal(httpResultToResponse(HttpResult.UnprocessableEntity("u"), id).status, 422);
  assert.equal(httpResultToResponse(HttpResult.TooManyRequests("t"), id).status, 429);
  assert.equal(httpResultToResponse(HttpResult.UnavailableForLegalReasons("l"), id).status, 451);
  // 5xx server error.
  assert.equal(httpResultToResponse(HttpResult.ServerError("s"), id).status, 500);
  assert.equal(httpResultToResponse(HttpResult.NotImplemented("n"), id).status, 501);
  assert.equal(httpResultToResponse(HttpResult.BadGateway("g"), id).status, 502);
  assert.equal(httpResultToResponse(HttpResult.ServiceUnavailable("s"), id).status, 503);
  assert.equal(httpResultToResponse(HttpResult.GatewayTimeout("g"), id).status, 504);
});

test("httpResultToResponse: Streaming frames a Stream as SSE", async () => {
  async function* events() {
    yield "tick-1";
    yield "multi\nline";
  }
  const res = httpResultToResponse(HttpResult.Streaming(events()), id);
  assert.equal(res.status, 200);
  assert.equal(res.headers.get("content-type"), "text/event-stream");
  assert.equal(res.headers.get("cache-control"), "no-cache");
  // Each element is one SSE event; a multi-line element becomes multiple
  // `data:` lines, each event terminated by a blank line.
  assert.equal(await res.text(), "data: tick-1\n\ndata: multi\ndata: line\n\n");
});

test("httpResultToResponse: redirects carry a Location header and no body", async () => {
  const res = httpResultToResponse(HttpResult.Found("https://bynk.dev/target"), id);
  assert.equal(res.status, 302);
  assert.equal(res.headers.get("location"), "https://bynk.dev/target");
  assert.equal(await res.text(), "");
});

test("httpResultToResponse: TooManyRequests carries an { error } body", async () => {
  const res = httpResultToResponse(HttpResult.TooManyRequests("slow down"), id);
  assert.equal(res.status, 429);
  assert.deepEqual(await res.json(), { error: "slow down" });
});

test("httpResultToResponse: Ok carries the serialised value; NoContent is empty", async () => {
  const ok = httpResultToResponse(HttpResult.Ok(42), id);
  assert.equal(await ok.json(), 42);
  const empty = httpResultToResponse(HttpResult.NoContent, id);
  assert.equal(await empty.text(), "");
});

test("httpResultToResponse: error variants carry an { error } body", async () => {
  const res = httpResultToResponse(HttpResult.BadRequest("bad input"), id);
  assert.deepEqual(await res.json(), { error: "bad input" });
});

test("headResponse: preserves status and headers, empties the body", async () => {
  const res = headResponse(httpResultToResponse(HttpResult.Ok(1), id));
  assert.equal(res.status, 200);
  assert.equal(res.headers.get("content-type"), "application/json");
  assert.equal(await res.text(), "");
  assert.equal(res.body, null);
});

test("headResponse: does not drain a Streaming body", async () => {
  let completed = false;
  const stream = (async function* () {
    yield "first";
    yield "second";
    completed = true;
  })();
  const res = headResponse(httpResultToResponse(HttpResult.Streaming(stream), id));
  assert.equal(res.status, 200);
  assert.equal(res.headers.get("content-type"), "text/event-stream");
  assert.equal(res.body, null);
  // Give any eagerly-scheduled stream work a turn: the discarded SSE
  // ReadableStream is never consumed by a reader, so the generator never runs
  // to completion (it is not drained).
  await new Promise((r) => setTimeout(r, 0));
  assert.equal(completed, false);
});

test("applyCors: reflects a matched origin and sets Vary + credentials", () => {
  const res = applyCors(new Response("ok"), listPolicy, "https://app.example.com");
  assert.equal(res.headers.get("access-control-allow-origin"), "https://app.example.com");
  assert.equal(res.headers.get("vary"), "Origin");
  assert.equal(res.headers.get("access-control-allow-credentials"), "true");
});

test("applyCors: omits ACAO for a non-allowlisted origin (fail closed)", () => {
  const res = applyCors(new Response("ok"), listPolicy, "https://evil.example.com");
  assert.equal(res.headers.get("access-control-allow-origin"), null);
  assert.equal(res.headers.get("vary"), null);
});

test("applyCors: wildcard answers any origin with * and no Vary", () => {
  const res = applyCors(new Response("ok"), wildcardPolicy, "https://anything.example.com");
  assert.equal(res.headers.get("access-control-allow-origin"), "*");
  assert.equal(res.headers.get("vary"), null);
  assert.equal(res.headers.get("access-control-allow-credentials"), null);
});

test("corsPreflightResponse: 204 with methods/headers/max-age for a matched origin", () => {
  const res = corsPreflightResponse(listPolicy, "https://admin.example.com");
  assert.equal(res.status, 204);
  assert.equal(res.headers.get("access-control-allow-origin"), "https://admin.example.com");
  assert.equal(res.headers.get("access-control-allow-methods"), "GET, POST, OPTIONS");
  assert.equal(res.headers.get("access-control-allow-headers"), "content-type, authorization");
  assert.equal(res.headers.get("access-control-max-age"), "3600");
  assert.equal(res.headers.get("access-control-allow-credentials"), "true");
});

test("corsPreflightResponse: 204 without a grant for a non-allowlisted origin", () => {
  const res = corsPreflightResponse(listPolicy, "https://evil.example.com");
  assert.equal(res.status, 204);
  assert.equal(res.headers.get("access-control-allow-origin"), null);
  assert.equal(res.headers.get("access-control-allow-methods"), null);
});

// v0.141 (ADR 0164): security response headers — nosniff on by default, HSTS opt-in.

const defaultSecurity: SecurityPolicy = { nosniff: true, hstsMaxAgeSecs: null };
const hstsSecurity: SecurityPolicy = { nosniff: true, hstsMaxAgeSecs: 15552000 };
const optedOutSecurity: SecurityPolicy = { nosniff: false, hstsMaxAgeSecs: null };

test("applySecurityHeaders: default stamps nosniff, no HSTS", () => {
  const res = applySecurityHeaders(new Response("ok"), defaultSecurity);
  assert.equal(res.headers.get("x-content-type-options"), "nosniff");
  assert.equal(res.headers.get("strict-transport-security"), null);
});

test("applySecurityHeaders: HSTS opt-in stamps max-age alongside nosniff", () => {
  const res = applySecurityHeaders(new Response("ok"), hstsSecurity);
  assert.equal(res.headers.get("x-content-type-options"), "nosniff");
  assert.equal(res.headers.get("strict-transport-security"), "max-age=15552000");
});

test("applySecurityHeaders: nosniff:false opts out of every security header", () => {
  const res = applySecurityHeaders(new Response("ok"), optedOutSecurity);
  assert.equal(res.headers.get("x-content-type-options"), null);
  assert.equal(res.headers.get("strict-transport-security"), null);
});

test("applySecurityHeaders: composes with applyCors (disjoint headers, order-independent)", () => {
  const res = applySecurityHeaders(
    applyCors(new Response("ok"), listPolicy, "https://app.example.com"),
    hstsSecurity,
  );
  // CORS headers survive the security stamp...
  assert.equal(res.headers.get("access-control-allow-origin"), "https://app.example.com");
  // ...and the security headers are present too.
  assert.equal(res.headers.get("x-content-type-options"), "nosniff");
  assert.equal(res.headers.get("strict-transport-security"), "max-age=15552000");
});

// v0.140 (ADR 0163): conditional caching — weak ETag, opt-in freshness, 304.

test("weakETag: stable, weak, and body-sensitive", () => {
  const a = weakETag('{"n":1}');
  assert.equal(a, weakETag('{"n":1}')); // deterministic
  assert.match(a, /^W\/"[0-9a-z]+"$/); // weak, quoted, compact
  assert.notEqual(a, weakETag('{"n":2}')); // changes with the body
});

test("httpResultToResponse: weakEtag opt-in stamps ETag only on Ok", async () => {
  const ok = httpResultToResponse(HttpResult.Ok(1), id, { weakEtag: true });
  assert.equal(ok.status, 200);
  assert.equal(ok.headers.get("etag"), weakETag("1"));
  assert.equal(await ok.text(), "1");

  // Created/Accepted are not eligible even with the flag; the body is unchanged.
  assert.equal(
    httpResultToResponse(HttpResult.Created(1), id, { weakEtag: true }).headers.get("etag"),
    null,
  );

  // Without the opt-in, an Ok is byte-identical to the pre-caching behaviour.
  assert.equal(httpResultToResponse(HttpResult.Ok(1), id).headers.get("etag"), null);
});

test("applyCache: sets Cache-Control from scope + maxAge, in place", () => {
  const res = applyCache(httpResultToResponse(HttpResult.Ok(1), id, { weakEtag: true }), 300, "private");
  assert.equal(res.headers.get("cache-control"), "private, max-age=300");
  assert.equal(
    applyCache(httpResultToResponse(HttpResult.Ok(1), id), 60, "public").headers.get("cache-control"),
    "public, max-age=60",
  );
});

test("notModifiedIfMatch: matching If-None-Match yields an empty 304 with ETag + Cache-Control", async () => {
  const built = applyCache(httpResultToResponse(HttpResult.Ok(1), id, { weakEtag: true }), 300, "private");
  const etag = built.headers.get("etag")!;
  const req = new Request("https://x/y", { headers: { "if-none-match": etag } });
  const res = notModifiedIfMatch(built, req);
  assert.equal(res.status, 304);
  assert.equal(res.headers.get("etag"), etag);
  assert.equal(res.headers.get("cache-control"), "private, max-age=300");
  assert.equal(await res.text(), ""); // empty body
});

test("notModifiedIfMatch: stale or absent If-None-Match passes the 200 through", async () => {
  const built = httpResultToResponse(HttpResult.Ok(1), id, { weakEtag: true });
  // Stale validator.
  const stale = notModifiedIfMatch(
    built,
    new Request("https://x/y", { headers: { "if-none-match": 'W/"deadbeef"' } }),
  );
  assert.equal(stale.status, 200);
  assert.equal(await stale.clone().text(), "1");
  // No If-None-Match at all.
  assert.equal(notModifiedIfMatch(built, new Request("https://x/y")).status, 200);
});

test("notModifiedIfMatch: handles a list and the wildcard, and never 304s a body with no ETag", () => {
  const built = httpResultToResponse(HttpResult.Ok(1), id, { weakEtag: true });
  const etag = built.headers.get("etag")!;
  // A comma-separated list containing the tag matches.
  const list = new Request("https://x/y", { headers: { "if-none-match": `W/"other", ${etag}` } });
  assert.equal(notModifiedIfMatch(built, list).status, 304);
  // `*` matches any current representation.
  const star = new Request("https://x/y", { headers: { "if-none-match": "*" } });
  assert.equal(notModifiedIfMatch(built, star).status, 304);
  // A response with no ETag (e.g. a Streaming/Raw GET) is never revalidated.
  const noEtag = httpResultToResponse(HttpResult.Ok(1), id);
  assert.equal(notModifiedIfMatch(noEtag, star).status, 200);
});
